diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dc868a2..58c947c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,34 +72,6 @@ jobs: - name: Build desktop pipeline run: corepack yarn build:desktop - - name: Windows source launcher runtime self-test - if: runner.os == 'Windows' - shell: pwsh - run: | - $resultPath = Join-Path $env:RUNNER_TEMP "cafecode-source-runtime-self-test.json" - $env:CAFE_CODE_RUNTIME_SELF_TEST_RESULT = $resultPath - & .\Start-CafeCode.ps1 -Wait -DesktopArgs "--cafe-runtime-self-test" - if ($LASTEXITCODE -ne 0) { - Get-Content -LiteralPath (Join-Path $env:USERPROFILE ".cafe-code\launcher-logs\desktop-start.stderr.log") -ErrorAction SilentlyContinue - throw "Windows source launcher runtime self-test exited with code $LASTEXITCODE." - } - $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json - if (-not $result.ok -or $result.isPackaged) { - throw "Windows source launcher runtime self-test reported failure." - } - - - name: macOS source runtime self-test - if: runner.os == 'macOS' - shell: bash - run: | - result_path="$RUNNER_TEMP/cafecode-source-runtime-self-test.json" - CAFE_CODE_RUNTIME_SELF_TEST_RESULT="$result_path" corepack yarn workspace @cafecode/desktop exec electron dist-electron/main.cjs --cafe-runtime-self-test - node -e 'const fs=require("node:fs"); const result=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(!result.ok || result.isPackaged) process.exit(1)' "$result_path" - - - name: Release smoke - if: runner.os == 'Linux' - run: corepack yarn release:smoke - - name: Install browser test runtime if: runner.os == 'Linux' run: corepack yarn workspace @cafecode/web exec playwright install --with-deps chromium @@ -154,14 +126,6 @@ jobs: - name: Build native artifact run: corepack yarn ${{ matrix.command }} - - name: Smoke Windows installer and packaged runtime - if: runner.os == 'Windows' - run: corepack yarn test:native-windows-artifact - - - name: Smoke macOS images and packaged runtime - if: runner.os == 'macOS' - run: corepack yarn test:native-macos-artifact - - name: Upload native artifact uses: actions/upload-artifact@v6 with: @@ -202,19 +166,6 @@ jobs: - name: Build AppImage run: corepack yarn dist:desktop:linux - - name: Install packaged runtime smoke dependencies - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y dbus-x11 gnome-keyring libasound2t64 libgbm1 libnspr4 libnss3 libsecret-1-0 xauth xvfb - - - name: Smoke AppImage runtime and artifact - shell: bash - run: | - dbus-run-session -- bash -euo pipefail <<'SCRIPT' - eval "$(printf '%s\n' cafecode-ci-keyring | gnome-keyring-daemon --unlock --components=secrets)" - xvfb-run --auto-servernum corepack yarn test:native-linux-artifact - SCRIPT - - name: Upload AppImage uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0c1b5b34 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,304 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.ref_name }} + cancel-in-progress: false + +env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-24.04 + outputs: + channel: ${{ steps.release.outputs.channel }} + prerelease: ${{ steps.release.outputs.prerelease }} + probe-version: ${{ steps.release.outputs.probe-version }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Validate release tag + id: release + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version="${tag#v}" + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + channel="latest" + prerelease="false" + probe_version="${version}-canary.0" + elif [[ "${version}" =~ ^([0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8})\.([1-9][0-9]*)$ ]]; then + channel="nightly" + prerelease="true" + probe_version="${BASH_REMATCH[1]}.0" + else + echo "Unsupported release tag: ${tag}" >&2 + echo "Expected vX.Y.Z or vX.Y.Z-nightly.YYYYMMDD.N where N is at least 1." >&2 + exit 1 + fi + { + echo "channel=${channel}" + echo "prerelease=${prerelease}" + echo "probe-version=${probe_version}" + echo "version=${version}" + } >> "${GITHUB_OUTPUT}" + + quality: + name: Release quality + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Cache Yarn and Turbo + uses: actions/cache@v5 + with: + path: | + ~/.yarn/berry + .turbo + key: ${{ runner.os }}-${{ runner.arch }}-release-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('turbo.json') }} + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Verify source + run: | + corepack yarn fmt:check + corepack yarn lint + corepack yarn typecheck + corepack yarn test + corepack yarn build:desktop + + artifacts: + name: Build ${{ matrix.name }} + needs: [prepare, quality] + strategy: + fail-fast: false + matrix: + include: + - name: windows-x64 + os: windows-2025 + command: dist:desktop:win:x64 + - name: macos-arm64 + os: macos-15 + command: dist:desktop:dmg:arm64 + - name: macos-x64 + os: macos-15-intel + command: dist:desktop:dmg:x64 + - name: linux-x64 + os: ubuntu-24.04 + command: dist:desktop:linux + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + CAFE_CODE_DESKTOP_SIGNED: "false" + CAFE_CODE_DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Cache Yarn and Turbo + uses: actions/cache@v5 + with: + path: | + ~/.yarn/berry + .turbo + key: ${{ runner.os }}-${{ runner.arch }}-release-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('turbo.json') }} + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Build unsigned artifact + run: corepack yarn ${{ matrix.command }} + + - name: Build lower-version AppImage probe + if: matrix.name == 'linux-x64' + env: + CAFE_CODE_DESKTOP_OUTPUT_DIR: release-probe + CAFE_CODE_DESKTOP_VERSION: ${{ needs.prepare.outputs.probe-version }} + run: corepack yarn dist:desktop:linux + + - name: Upload release artifact + uses: actions/upload-artifact@v6 + with: + name: cafe-code-release-${{ matrix.name }} + path: release/ + if-no-files-found: error + retention-days: 7 + + - name: Upload packaged detection probe + if: matrix.name == 'linux-x64' + uses: actions/upload-artifact@v6 + with: + name: cafe-code-update-probe-linux-x64 + path: release-probe/*.AppImage + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish unsigned release + needs: [prepare, artifacts] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24.13.1 + + - name: Activate pinned Yarn + run: | + corepack enable + corepack prepare yarn@4.17.1 --activate + + - name: Install dependencies + run: corepack yarn install --immutable + + - name: Download native artifacts + uses: actions/download-artifact@v7 + with: + pattern: cafe-code-release-* + path: release-assets + + - name: Assemble update feeds + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p release-publish + + for file in release-assets/cafe-code-release-windows-x64/*; do + case "${file}" in + *.exe|*.blockmap|*/${RELEASE_CHANNEL}.yml) cp "${file}" release-publish/ ;; + esac + done + for file in release-assets/cafe-code-release-linux-x64/*; do + case "${file}" in + *.AppImage|*.blockmap|*/${RELEASE_CHANNEL}-linux.yml) cp "${file}" release-publish/ ;; + esac + done + for arch in arm64 x64; do + for file in release-assets/cafe-code-release-macos-${arch}/*; do + case "${file}" in + *.dmg|*.zip|*.blockmap) cp "${file}" release-publish/ ;; + esac + done + done + + node scripts/merge-update-manifests.ts \ + --platform mac \ + "release-assets/cafe-code-release-macos-arm64/${RELEASE_CHANNEL}-mac.yml" \ + "release-assets/cafe-code-release-macos-x64/${RELEASE_CHANNEL}-mac.yml" \ + "release-publish/${RELEASE_CHANNEL}-mac.yml" + + - name: Validate release manifests and checksums + run: >- + corepack yarn validate:desktop-release + --release-dir release-publish + --version "${RELEASE_VERSION}" + --channel "${RELEASE_CHANNEL}" + + - name: Create GitHub release + shell: bash + run: | + set -euo pipefail + args=( + "${GITHUB_REF_NAME}" + release-publish/* + --verify-tag + --title "Cafe Code ${RELEASE_VERSION}" + --notes "Unsigned desktop release. Windows uses NSIS, macOS provides arm64/x64 DMGs, and Linux support is x64 AppImage only. Verify manual downloads with SHA256SUMS.txt." + ) + if [[ "${{ needs.prepare.outputs.prerelease }}" == "true" ]]; then + args+=(--prerelease) + fi + gh release create "${args[@]}" + + detect-update: + name: Detect published GitHub update + needs: [prepare, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Download lower-version packaged probe + uses: actions/download-artifact@v7 + with: + name: cafe-code-update-probe-linux-x64 + path: update-probe + + - name: Run detection-only AppImage probe + shell: bash + env: + CAFE_CODE_UPDATE_DETECTION_CHANNEL: ${{ needs.prepare.outputs.channel }} + CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION: ${{ needs.prepare.outputs.version }} + CAFE_CODE_UPDATE_DETECTION_RESULT: ${{ runner.temp }}/cafe-update-detection.json + run: | + set -euo pipefail + probe=(update-probe/*.AppImage) + if [[ ! -f "${probe[0]}" ]]; then + echo "Packaged update probe AppImage is missing." >&2 + exit 1 + fi + chmod 0755 "${probe[0]}" + probe_path="$(realpath "${probe[0]}")" + probe_log="${RUNNER_TEMP}/cafe-update-detection.log" + if ! env APPIMAGE="${probe_path}" APPIMAGE_EXTRACT_AND_RUN=1 \ + "${probe_path}" \ + --no-sandbox \ + --headless \ + --disable-gpu \ + --cafe-update-detection-probe \ + > "${probe_log}" 2>&1; then + tail -100 "${probe_log}" >&2 + exit 1 + fi + tail -20 "${probe_log}" + node -e ' + const fs = require("node:fs"); + const result = JSON.parse(fs.readFileSync(process.env.CAFE_CODE_UPDATE_DETECTION_RESULT, "utf8")); + if (!result.ok || !result.updateAvailable || result.availableVersion !== process.env.CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION) { + console.error(result); + process.exit(1); + } + console.log(JSON.stringify(result)); + ' diff --git a/AGENTS.md b/AGENTS.md index 2702c49c..118674cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - Keep Windows-only fixes documented in this section before implementation. Scope them so they do not change macOS or Linux behavior unless the task explicitly asks for a cross-platform change. Document how macOS/Linux behavior is preserved, and verify at least `yarn build:desktop` plus the relevant Windows command before considering a Windows fix complete. - Windows NSIS packaging uses `electron-builder`, which rejects a staged app if `electron` appears in `dependencies`; Electron must be present only in `devDependencies` for the staged builder package. Because `apps/server/package.json` currently lists `electron` among runtime dependencies, `scripts/build-desktop-artifact.ts` must omit `electron` from staged runtime dependencies while still adding the desktop Electron version to staged `devDependencies`. This is a staged package shape fix, not a runtime dependency or platform-target change. It preserves macOS/Linux behavior by leaving shared build config, platform target selection, icons, signing/publish configuration, and non-Electron staged runtime dependencies unchanged. - Unsigned Windows NSIS artifacts should set `win.signExecutable=false`, not `win.signAndEditExecutable=false`. This skips code signing while preserving executable resource editing and prevents NSIS helper/uninstaller signing paths from running during local unsigned builds. Keep signed Azure Trusted Signing builds on the existing `azureSignOptions` path. +- Unsigned Windows GitHub releases use the NSIS updater path with manifest SHA-512 validation but no Authenticode publisher validation. The tagged release workflow must keep `CAFE_CODE_DESKTOP_SIGNED=false`, publish the x64 installer plus its complete update manifest, and run the installer's existing `--updated` path without rerunning fresh-install managed-provider bootstrap. This Windows policy does not change unsigned macOS behavior (manual DMG replacement because Squirrel.Mac requires signing) or Linux behavior (x64 AppImage only). - Windows Codex shadow homes must not require Developer Mode or administrator symlink privileges. Keep macOS/Linux on the existing symlink-based layout, but on Windows use Windows-safe links for shared entries: directory junctions for shared directories, hard links or copies for shared files, and real copied private files for `auth.json`. Verify the Windows path without changing POSIX shadow-home behavior. - Windows Store/MSIX Codex installs expose private app resources under `C:\Program Files\WindowsApps` that can appear on `PATH` but fail with Access Denied outside the package identity. Windows-only Codex CLI autodetection should prefer user-owned Codex cache bins under `%LOCALAPPDATA%\OpenAI\Codex\bin\` ahead of WindowsApps resources. Do not apply this path hydration on macOS or Linux, and keep explicit user-configured `binaryPath` values authoritative. - Windows Codex and Claude provider instances can choose `runtimeSource: "system"` or `runtimeSource: "bundled"`. System mode is the default and preserves existing `binaryPath`/PATH behavior on every platform. Bundled mode is Windows-only: it must resolve provider shims and npm from Cafe-managed, user-writable paths under `%LOCALAPPDATA%\CafeCode\managed` or explicit Cafe managed-runtime environment overrides, never from global PATH, and macOS/Linux must report the bundled runtime as unsupported rather than silently falling back to system binaries. @@ -30,12 +31,19 @@ - Windows provider health checks can receive localized CLI failure text in the active Windows code page instead of UTF-8. Preserve UTF-8 decoding on macOS/Linux, but on Windows fall back only when the byte stream is invalid UTF-8 so settings diagnostics do not show mojibake. - Windows tests must not assume Developer Mode or administrator symlink privileges. If a test specifically validates symlink handling and Windows returns a symlink privilege `EPERM`, skip only that privilege-dependent assertion path while preserving the full symlink security test on macOS/Linux and on Windows machines where symlinks are available. - Windows file URL conversion must use `fileURLToPath` instead of `URL.pathname`, because raw pathname strings keep a leading slash before the drive letter. This is safe for macOS/Linux and prevents Windows-only false negatives when tests resolve repo-relative assets. -- Windows test assertions that run through the host `Path.Path` service must build expected paths through that same path service instead of hard-coded POSIX separators. Keep macOS/Linux behavior unchanged by deriving expected strings from the platform path implementation already under test. +- Windows test assertions that run through the host `Path.Path` service or Node path API must build expected paths through that same path implementation instead of hard-coded POSIX separators. Cross-platform launcher tests may select Linux behavior while executing on Windows, but their filesystem fixtures still need host-native paths because that is what the production launcher receives. Keep macOS/Linux behavior unchanged by deriving expected strings from the host path implementation already under test. +- Windows can report one directory through different casing, separators, long names, or 8.3 aliases. Git/worktree safety checks must compare stable filesystem identity when available instead of raw path spelling, but opaque device/inode identities are comparison keys only and must never be passed to Git or Node as a working directory; status-cache loaders must retain a canonical usable path. Tests for simulated Linux/macOS command-selection policy must inject command availability rather than asking the Windows host filesystem to interpret a foreign `PATH`; real Windows provider-process smoke fixtures must use `.cmd` shims. A helper launched through `cmd.exe` must not wait indefinitely for stdin EOF: verify prompt delivery from data, use a bounded deadline, and exit the helper explicitly so the shell cannot retain the pipeline. Windows has no `/proc/self/fd` or `/dev/fd` duplication path, so a bootstrap descriptor handed to the direct stream becomes stream-owned and must not also be closed by the caller. POSIX FIFO timeout coverage stays POSIX-only. +- Windows cannot directly spawn Yarn's `.cmd` command shims through the shell-free Effect child-process API. Custom Oxlint fixture tests must invoke `node_modules/oxlint/bin/oxlint` through `process.execPath`; keep `shell: false` so temporary fixture paths are passed as structured arguments, and preserve the same JavaScript entrypoint path on macOS/Linux. - Windows subprocess and heavy dynamic-import tests can need longer per-test timeouts under full parallel `turbo` load. Apply longer timeouts only under `process.platform === "win32"` and preserve existing macOS/Linux timeouts. - Windows process-table diagnostics must spawn `powershell.exe` directly with `shell: false`; do not route the CIM pipeline through `cmd.exe` or any shell wrapper because `cmd.exe` can misparse PowerShell pipeline syntax before PowerShell receives it. Keep macOS/Linux on the existing `ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=` path. Windows CPU/perf lookup should remain batched once per diagnostics read instead of querying performance counters once per process row. The longer diagnostics timeout exists for Windows CIM latency; if future work needs a tighter POSIX timeout, split the timeout by platform rather than regressing Windows reliability. - Windows `cafe-code killall` process discovery must also spawn `powershell.exe` directly with `shell: false` and consume `Get-CimInstance Win32_Process` JSON. Keep macOS/Linux on targeted `ps -axo pid=,ppid=,command=` discovery, and keep the command's current process plus ancestors protected so `cafe-code killall` does not terminate its own launcher before it finishes reporting results. -- Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. +- Windows source checkouts may start Cafe through `Start-CafeCode.ps1`, which checks whether `openssl.exe`/`openssl` is available as an application on `PATH` before launch. On Windows CI and developer machines, `Get-Command` can return multiple Node application matches (for example an `actions/setup-node` toolcache path plus a preinstalled Node path), so the launcher must select one executable path deterministically before probing `--version` or calling `Start-Process`; do not concatenate multiple matches into one command string. The launcher should preserve default local HTTPS when OpenSSL is present, and set `CAFE_CODE_HTTPS_ENABLED=false` only when OpenSSL is missing because source installs do not always have it available. Desktop backend configuration must also propagate `CAFE_CODE_HTTPS_ENABLED=false` whenever `DesktopServerExposure` resolves no HTTPS port, while keeping macOS/Linux behavior unchanged by deriving the child environment from the shared exposure config rather than from a platform branch. - Windows packaged desktop startup must also verify that `openssl.exe`/`openssl` can run before allocating an HTTPS sibling port. If OpenSSL is unavailable, keep the user's persisted HTTPS preference unchanged but start the backend HTTP-only for that run so certificate generation cannot crash-loop before the window opens. macOS/Linux behavior remains unchanged because the packaged runtime only applies this OpenSSL prerequisite gate on Windows. +- Git for Windows OpenSSL can take longer than 20 seconds to generate an RSA certificate while the host is under the full parallel `turbo` test load. Allow the certificate subprocess up to 60 seconds and the Windows server test process up to 90 seconds so the parent timeout does not race it. Keep the existing 20-second OpenSSL subprocess timeout and 60-second server test timeout on macOS/Linux. +- Windows NSIS artifact smoke cleanup can see short-lived `EBUSY`/`EPERM`/`ENOTEMPTY` directory removal failures immediately after the packaged app or uninstaller exits because Windows can release process/file handles slightly after the parent process observes exit. NSIS uninstall can also briefly leave the branded application executable present immediately after the uninstaller process reports exit while post-uninstall deletion is still draining. Keep macOS/Linux cleanup on the existing single-pass removal path, but on Windows native-artifact smoke scripts retry temp directory removal and wait a bounded window for uninstall side effects before treating them as real failures. +- Windows managed-runtime installer outputs can be valid UTF-8 JSON files with a leading BOM when produced through Windows-native writers. Keep macOS/Linux JSON parsing unchanged, but Windows artifact/native-runtime smoke helpers must tolerate an optional leading BOM before `JSON.parse` so bundled-provider install verification does not fail on otherwise valid JSON. +- Windows managed provider CLI shims installed by npm do not carry a private `node.exe`; they expect the managed provider `.bin` directory and the managed Node directory on `PATH`, plus the managed npm prefix/cache environment, exactly as the packaged bundled-runtime launcher provides. Keep macOS/Linux artifact smoke unchanged, but Windows native-artifact smoke must probe `codex.cmd`/`claude.cmd` with that Cafe-managed environment instead of assuming the ambient user PATH can run the shims directly. +- Windows batch shims launched through `cmd.exe /c` need the entire command string quoted when the shim path itself is quoted, for example `""C:\path\codex.cmd" --version"`. Without that outer quote pair, `cmd.exe` treats the quoted filename token as the literal command name and reports `"path\codex.cmd"` as not recognized even when the managed PATH/npm environment is correct. When spawning that `/c` payload from Node on Windows, also set `windowsVerbatimArguments=true`; otherwise Node escapes the outer quotes during Windows command-line serialization and `cmd.exe` still treats the whole payload as a literal command name. Keep macOS/Linux smoke paths unchanged. - Windows source and artifact builds require the pinned standalone Node runtime. Server build helpers should invoke TypeScript entrypoints through `process.execPath` and use Corepack only for locked Yarn package operations. Preserve the direct Node path on macOS/Linux and avoid platform-specific package-manager shims. ## Project Snapshot @@ -129,9 +137,11 @@ Cafe Code has three important runtime layers: - Main backend/server: owns app-level orchestration, event sourcing, projections, settings, WebSocket pushes, HTTP routes, and durable persistence. - Provider runtime process: the provider daemon owns long-running provider adapters and live sessions by default so provider work can survive UI/backend restarts when possible. A persistent provider supervisor still exists as an explicit runtime role, but automatic provider-daemon handoff to that extra supervisor hop is disabled until supervisor restart/fallback preserves the provider registry and active-session truth under dead IPC sockets. - Renderer environment runtime: the renderer always has one primary backend connection and may hydrate additional saved Cafe server connections. Every connection owns its authenticated RPC client and projection subscriptions; environment routing must remain explicit and must not change the primary backend's identity. +- Packaged desktop updates use stable `latest` or prerelease `nightly` GitHub Release feeds. Eligible builds check after startup and on a bounded poll without automatic download. Unsigned Windows x64 NSIS and Linux x64 AppImage builds expose explicit user-driven download/install, while unsigned macOS arm64/x64 builds expose detection plus an exact GitHub release link for manual DMG replacement because Squirrel.Mac requires code signing. Tagged releases validate manifest asset sizes and SHA-512 hashes before publication, then run a lower-version packaged AppImage detection-only probe against the public feed. Source-checkout Git branch diagnostics remain separate from packaged releases. - The desktop backend manager treats both sustained repeated backend HTTP health failures and bootstrap readiness timeouts as terminal backend run conditions even if the child process remains alive after termination is requested. This prevents a split-brain state where the backend PID is still present but the HTTP listener is gone, leaving the renderer unable to reconnect and blocking the manager forever on process `exitCode`. The post-readiness watchdog is intentionally more patient than bootstrap readiness because large long-running workspaces can briefly push SQLite/projection work over a one-second health probe. Do not lower the watchdog to short one-second failure windows; brief missed probes should log and recover, while a sustained outage should still restart the backend. On replacement startup, stale desktop backend children are reaped before binding the new backend; provider daemon/supervisor processes are intentionally excluded so provider sessions can survive the recovery. - The desktop checks the detached provider daemon every five seconds through the capability-authenticated `/api/provider-daemon/liveness` route. That route is a database-free process-identity boundary and must never read provider adapters, SQLite, journals, command ledgers, projections, or supervisor state. The richer `/api/provider-daemon/health` route remains available for cached debug/settings diagnostics, but it must never drive destructive watchdog recovery: on very large long-running databases, its diagnostic reads can legitimately exceed the request timeout while the daemon and provider sessions remain alive. A dead daemon PID triggers recovery after the first failed liveness probe; otherwise Cafe requires three consecutive liveness failures so transient event-loop pressure does not destroy a live long-running provider session. Recovery stops the backend first, replaces the authenticated daemon, and then starts a backend with the replacement daemon's newly issued lease. The daemon lease is inherited bootstrap capability data and must never be mutated in a running backend or copied through process argv/environment. Preserve and test the stop-backend -> recover-daemon -> start-backend order. - A detached provider daemon must not keep stdout/stderr pipes owned by Electron. Those pipes close when the desktop process restarts and can kill the surviving daemon with `EPIPE` on a later log write. Provider daemon and supervisor stdio therefore stays ignored, while each child owns a role-specific durable trace (`provider-daemon.trace.ndjson` or `provider-supervisor.trace.ndjson`) plus the existing bounded provider event logs. Do not redirect both detached runtimes into the backend's rotating `server.trace.ndjson`, because independent processes cannot safely coordinate that file's rotation. +- Yarn source and staged artifact installs use the `@cafecode/desktop-runtime` workspace postinstall as the trusted `scripts/ensure-desktop-runtime.ts` boundary. Keep the verifier on this workspace instead of the root package: Yarn must finish the workspace's Electron and `node-pty` dependency builds before verification, especially when Linux compiles `node-pty` from source. The verifier checks the pinned Electron binary and, on macOS, restores executable bits on the exact `node-pty` `spawn-helper` selected alongside `pty.node`; node-pty compiles and consumes that helper only on macOS, while Linux validly builds only `pty.node` and uses `forkpty` directly. Open the macOS helper with `O_NOFOLLOW` and mutate permissions through the verified file descriptor so a symlink cannot redirect the chmod operation. Keep third-party lifecycle scripts denied by default except for the explicitly trusted native dependencies in root `dependenciesMeta`. - `cafe-code killall` and `cafe-code-server killall` are explicit user-requested process termination paths. They scan for Cafe Code launcher, desktop client, main server/backend, and provider daemon/supervisor processes, protect the current command process and its ancestors, terminate children before parents, and avoid printing raw process argv by default because argv can contain sensitive flags or paths. Important files: @@ -279,7 +289,8 @@ Codex account usage behavior to preserve: - Upstream Codex app-server exposes `account/rateLimits/read` and emits `account/rateLimits/updated`; both are backed by `BackendClient::get_rate_limits_many()`, which reads ChatGPT-backed usage from `/backend-api/wham/usage` and Codex API-backed usage from `/api/codex/usage`. - Cafe Code provider snapshots may expose a redacted `accountRateLimits` summary for authenticated Codex ChatGPT accounts: primary/secondary used percentages, window durations, reset timestamps, plan metadata, and credit metadata only. Never expose access tokens, refresh tokens, account IDs, raw auth JSON, or raw usage payloads to the renderer. - The normal provider settings badge path intentionally avoids spawning hidden Codex app-server processes. When it needs rate-limit metadata before a session exists, it may perform the same authenticated ChatGPT usage request shape upstream uses, with the provider's effective shadow `CODEX_HOME` auth copy and bounded timeout. Active app-server sessions should still prefer upstream `account/rateLimits/read`/`updated` protocol data when available. -- Codex account-usage snapshots must stay fresh during long work sessions. Refresh Codex provider snapshots every five minutes, and after `thread.turn.start` or `thread.turn.steer` enqueue a non-blocking refresh for the selected Codex provider instance, throttled to at most once per minute per instance. These refreshes must not block prompt dispatch or expose raw usage/auth payloads. +- Codex account-usage snapshots must stay fresh during long work sessions. Run the full Codex provider status probe every five minutes and on an explicit user refresh, but coalesce every overlapping full refresh for one instance into a single flight so initial, periodic, and manual requests cannot queue repeated `codex --version` / `codex login status` subprocesses. After `thread.turn.start` or `thread.turn.steer`, enqueue a non-blocking usage-only refresh for the selected Codex provider instance, throttled to at most once per minute per instance; this path refreshes the effective shadow auth copy and performs only the bounded, redacted account-usage HTTP request. It must never fall back to a full binary status probe, block prompt dispatch, or expose raw usage/auth payloads. Coalesce overlapping usage-only requests too, serialize all snapshot mutations, and restart stale asynchronous snapshot enrichment before it can overwrite newer usage data. +- Renderer `subscribeServerConfig` connections are read boundaries. They must return the latest cached provider snapshots and subscribe to future changes without forcing provider refreshes; reconnect storms must never execute provider CLI health or login probes. Managed provider startup, periodic refresh, explicit refresh commands, and provider-instance rebuilds own probe scheduling. - Upstream Codex TUI's usage-limit reset flow treats reset availability as account-derived state from `rateLimitResetCredits`: it refreshes `account/rateLimits/read` before reset dialogs, after consuming a reset credit, and when cached zero availability may be stale. Upstream Codex `rust-v0.143.0` can include redacted reset-credit rows with ids, reset type, status, grant/expiry times, title, and description; Cafe may carry those rows through provider snapshots for UI accuracy, but must never expose auth tokens, account ids, or raw usage payloads. If a Codex provider refresh omits account usage after auth/account churn, clear the Codex usage snapshot rather than preserving stale percentages from a previous account; Claude's event-sourced usage-window preservation is a separate provider-specific path. Important local files: @@ -302,7 +313,7 @@ Codex home rules: ## Claude Integration -Cafe Code's Claude adapter uses `@anthropic-ai/claude-agent-sdk`, currently pinned to v0.3.215 to match Claude Code v2.1.215. +Cafe Code's Claude adapter uses `@anthropic-ai/claude-agent-sdk`, currently pinned to v0.3.216 to match Claude Code v2.1.216. Claude lifecycle facts to preserve: @@ -310,17 +321,23 @@ Claude lifecycle facts to preserve: - `query()` can take an `AsyncIterable` prompt, which Cafe Code uses as a prompt queue for long-lived sessions. - Agent SDK 0.3.212 requires a host wrapping keyboard input to stamp `SDKUserMessage.origin = { kind: "human" }` explicitly. Cafe must apply that provenance only to actual user-submitted prompt/steer messages; never infer or overwrite `peer`, `channel`, or `task-notification` origins on provider-emitted messages. Missing origin is intentionally unattributed and fails closed at upstream human-trust gates. - Agent SDK 0.3.211 fixes streamed user-message replay ordering when partial messages are enabled and includes complete CLI stderr in process-exit errors. It also adds optional assistant timestamps and `resumed_from_incomplete_thinking`; assistant timestamps use the originating host clock and are display-only, so Cafe must retain provider receive order as lifecycle truth and must not sort or terminalize events by that timestamp. The resumed-thinking flag is SDK normalization metadata, not a new assistant item or turn boundary. The new rate-limit prefix exports remain alpha classification helpers rather than a durable protocol, and `BashToolOutput.timedOutAfterMs` is tool-result metadata rather than thread context-window usage. -- Official Claude Agent SDK streaming input mode is the supported interactive path for queued messages: the docs describe dynamic message queueing, real-time interruption, and natural multi-turn conversations for `AsyncIterable` prompts, while the installed SDK types document `streamInput()` as the multi-turn input pipe. Cafe therefore advertises Claude live steer support by validating Cafe's expected active turn id locally and queueing the follow-up into the active SDK prompt stream. Claude does not expose a Codex-style expected-turn RPC, so never claim upstream has acknowledged a specific turn beyond Cafe's local active-turn binding. +- Official Claude Agent SDK streaming input mode is the supported interactive path for queued messages: the Claude Code changelog documents Enter as queueing additional messages while Claude works, and the SDK's `AsyncIterable`/`streamInput()` path writes those messages into the live command queue without calling `interrupt()`. Cafe therefore advertises Claude live steer support by validating Cafe's expected active turn id locally and queueing the follow-up into the active SDK prompt stream. Claude does not expose a Codex-style expected-turn RPC, so never claim upstream has acknowledged a specific turn beyond Cafe's local active-turn binding. An explicit Cafe stop remains the only path that calls `Query.interrupt()`. +- Claude's public `ServerProvider.runtimeCapabilities.liveSteer` snapshot and its private adapter capability must both remain `supported`. The renderer cannot inspect the daemon adapter directly: it uses the provider snapshot to choose between `thread.turn.steer` and the legacy unsupported-provider interrupt path. Every pending, disabled, warning, authenticated, and refreshed Claude snapshot is stamped in `ClaudeDriver.withInstanceIdentity`; omitting that stamp decodes to `unsupported` and destructively turns a normal Claude correction into `Query.interrupt()`. Keep a cross-driver registry test plus an adapter assertion that queueing a steer makes zero interrupt calls. - The returned `Query` supports operations such as `interrupt()`, `setModel()`, `setPermissionMode()`, and `setMaxThinkingTokens(maxThinkingTokens, thinkingDisplay?)`. -- Claude Code 2.1.205+ advertises `interrupt_receipt_v1`; 2.1.206+ also advertises `msg_lifecycle_v1` and emits top-level `command_lifecycle` frames with `queued`, `started`, `completed`, `cancelled`, or `discarded` state. Agent SDK 0.3.212 parses the interrupt receipt but still omits `command_lifecycle` and the implemented `cancelAsyncMessage()` method from its public TypeScript `Query` surface. Cafe must UUID-stamp every streamed user input, decode these runtime frames through a narrow forward-compatible guard, keep normal lifecycle frames out of unhandled-message warnings, and use terminal lifecycle states to retire local delivery tracking. On interrupt, cancel only Cafe-owned UUIDs returned in `still_queued` or still locally known as submitted/queued; upstream receipts can include internal cron/auto-resume UUIDs, which Cafe must ignore. If cancellation cannot be confirmed, retire the Claude process before accepting more input so durable Cafe replay cannot race an unconfirmed provider copy. Never log prompt content in these diagnostics. +- Claude Code 2.1.205+ advertises `interrupt_receipt_v1`; 2.1.206+ also advertises `msg_lifecycle_v1` and emits top-level `command_lifecycle` frames with `queued`, `started`, `completed`, `cancelled`, or `discarded` state. Agent SDK 0.3.216 parses the interrupt receipt but still omits `command_lifecycle` and the implemented `cancelAsyncMessage()` method from its public TypeScript `Query` surface. Cafe must UUID-stamp every streamed user input, decode these runtime frames through a narrow forward-compatible guard, keep normal lifecycle frames out of unhandled-message warnings, and use terminal lifecycle states to retire local delivery tracking. On interrupt, cancel only Cafe-owned UUIDs returned in `still_queued` or still locally known as submitted/queued; upstream receipts can include internal cron/auto-resume UUIDs, which Cafe must ignore. If cancellation cannot be confirmed, retire the Claude process before accepting more input so durable Cafe replay cannot race an unconfirmed provider copy. Never log prompt content in these diagnostics. +- Claude streaming input can emit a `result` for one response segment and then immediately promote another queued Cafe message on the same process. This also happens after recoverable execution-error results: log the segment failure as a work-log warning, but do not terminalize the canonical Cafe turn while accepted input remains queued. SDK 0.3.216 adds `user_message_uuid` to successful results for exact correlation. A result must retire only its correlated Cafe input (falling back to the oldest lifecycle-started input for older runtimes and error results), and must not emit Cafe `turn.completed` while another Cafe-owned input remains queued or started. Authentication failures remain terminal because the session must be retired before any stale credentials can be reused. Finalize and reset only response-segment-scoped tool/text block state so the next Claude response may reuse stream block indexes while preserving the same canonical Cafe turn id. Claude may coalesce multiple queued inputs into one model turn, so a deferred result becomes terminal when the remaining tracked UUIDs reach completed lifecycle states; if a queued input is promoted after that result, discard the older deferred boundary and wait for the promoted segment's own result. This mirrors Cafe's Codex steer UX without pretending Claude exposes Codex's expected-turn RPC. +- Provider-service `sendTurn` must consult live adapter session state for every adapter advertising `liveSteer: "supported"`, not only for Codex. If Claude still owns a running turn, route the input through `steerTurn` so it enters Claude's non-interrupting queue. Inside the Claude adapter, only provider-initiated synthetic background turns may be auto-closed before a new user turn; a direct second `sendTurn` against a real active user turn must fail closed without mutating that turn, because the service is responsible for routing it through the queue. - Claude Code 2.1.208 / Agent SDK 0.3.208 introduced no new `SDKMessage` or system-subtype discriminants, but upstream fixed truncated headless `stream-json` output and missing terminal result messages, bounded several long-session SDK/CLI caches, cached permission-rule and MCP tool-pool assembly, and corrected transient long-context metadata after CLI auto-update. These fixes directly benefit Cafe's long-lived `AsyncIterable` sessions, so keep the SDK wrapper and system Claude Code binary on matching current patch releases. The SDK control wire shape now permits omitted `max_thinking_tokens` to reset a session override, while the public `Query.setMaxThinkingTokens()` method still requires `number | null`; do not synthesize an omitted control request through local casts. Claude Code also honors `CLAUDE_CODE_PROCESS_WRAPPER` for its own child/self-spawns, and Cafe's Claude environment must continue preserving explicitly supplied process/provider environment variables. - Claude Code 2.1.212 / Agent SDK 0.3.212 add no new public `SDKMessage` or system-subtype discriminants. The SDK adds settings metadata for `feedbackDrafts` and generated schemas for the built-in `SendFeedback` and `ProposeSkills` tools; Cafe's tool pipeline must continue handling unfamiliar built-ins as generic dynamic tool calls instead of rejecting or silently dropping them. Upstream also makes headless/SDK `set_model` control requests effective on the next model round-trip even when issued mid-turn and fixes streaming control requests being marked complete before their handlers finish. Cafe should continue awaiting `Query.setModel()` and must not locally infer model-switch completion before that promise resolves. The CLI patch additionally fixes plan-mode file-changing Bash commands bypassing the SDK `canUseTool` callback, prevents `.claude/worktrees` symlink traversal outside the repository, preserves `continue:false` hook halts, and kills running Bash process trees on SIGTERM; keep Cafe's SDK and system CLI matched so these security and lifecycle fixes apply consistently. -- Claude Code 2.1.214-2.1.215 / Agent SDK 0.3.215 add no new top-level `SDKMessage` or system-subtype discriminants, but 2.1.214 includes permission-analysis fixes and fixes truncated `stream-json` output for slow SDK consumers, so Cafe must keep the wrapper and system CLI on the matching patch. The SDK's `canUseTool` callback now exposes `matchedAskRule` when an explicit `permissions.ask` rule requires human confirmation; that specific user policy must override Cafe's broad full-access auto-allow path, and Cafe must not persist the rule's potentially sensitive `ruleContent` in request diagnostics. `tool_progress` now carries periodic `heartbeat`, optional `subagent_type`, and structured `subagent_retry`: treat every progress frame as active-turn liveness, do not project plain heartbeats into durable work-log activities, and persist at most one bounded `task.progress` row per concrete retry attempt. `SDKAssistantMessage.aborted` marks partial text cut off by interruption; preserve the partial output but let the result/interrupt lifecycle event remain terminal truth rather than inferring normal completion from the assistant snapshot. The new `EndConversation` built-in remains a generic dynamic tool call, `SessionStart` source `fork` is hook metadata, and the added Google Cloud account-provider value is account metadata rather than a new Cafe provider driver. +- Claude Code 2.1.214-2.1.216 / Agent SDK 0.3.216 add no new top-level `SDKMessage` or system-subtype discriminants. Version 2.1.214 includes permission-analysis fixes and fixes truncated `stream-json` output for slow SDK consumers; 0.3.216 adds result correlation/latency fields and tool-result metadata, so Cafe must keep the wrapper and system CLI on the matching patch. The SDK's `canUseTool` callback now exposes `matchedAskRule` when an explicit `permissions.ask` rule requires human confirmation; that specific user policy must override Cafe's broad full-access auto-allow path, and Cafe must not persist the rule's potentially sensitive `ruleContent` in request diagnostics. `tool_progress` now carries periodic `heartbeat`, optional `subagent_type`, and structured `subagent_retry`: treat every progress frame as active-turn liveness, do not project plain heartbeats into durable work-log activities, and persist at most one bounded `task.progress` row per concrete retry attempt. `SDKAssistantMessage.aborted` marks partial text cut off by interruption; preserve the partial output but let the result/interrupt lifecycle event remain terminal truth rather than inferring normal completion from the assistant snapshot. The new `EndConversation` built-in remains a generic dynamic tool call, `SessionStart` source `fork` is hook metadata, and the added Google Cloud account-provider value is account metadata rather than a new Cafe provider driver. - Start Claude sessions with `includePartialMessages: true` and `agentProgressSummaries: true`. `includePartialMessages` keeps assistant streaming granular, and upstream documents `agentProgressSummaries` as the supported way to receive periodic AI-written summaries for long-running foreground/background subagents. Do not replace this with system-prompt nagging; provider progress should come from SDK/runtime events and render through the work log. - Permission mode has two distinct paths in upstream Claude Agent SDK: initial `query()` options and `Query.setPermissionMode()` for an already-active streaming session. Cafe Code must bind the first turn's interaction mode into `query()` when starting a Claude session and must not send a redundant pre-prompt `setPermissionMode()` for default/full-access sends, because current Claude Code can reject that control request before it has a transcript message/conversation to attach it to. +- Claude Code 2.1.216 exposes four normal interactive permission states: Manual/Ask permissions (`default` on the Agent SDK control wire, with `manual` accepted by the CLI), Accept edits (`acceptEdits`), Plan (`plan`), and classifier-backed Auto (`auto`). Cafe's Claude composer must present those as one provider-specific mode selector rather than an independent Build/Plan toggle plus an ambiguous access selector, and focused-composer Shift+Tab must cycle through those four states in that order. Preserve Bypass permissions (`bypassPermissions`) as a separately and dangerously labeled optional state outside the normal shortcut cycle because older Cafe Full access threads already persist that policy; do not mislabel it as Auto. Upstream keeps `dontAsk` CLI-only, so Cafe's desktop-style selector does not expose it. Persist Auto as `interactionMode: "auto"`; preserve the underlying generic runtime policy while Plan or Auto is selected so live `Query.setPermissionMode()` transitions do not require a provider restart. If a thread later switches to Codex, normalize every non-plan Claude interaction value to Codex `default`, because Codex app-server accepts only `default` and `plan` collaboration modes. +- Claude Auto mode's classifier is authoritative. The Agent SDK invokes Cafe's `canUseTool` callback when Auto falls back to a human decision; Cafe must never auto-allow that callback merely because the thread's older generic runtime policy is `full-access`. Only an actually active `bypassPermissions` SDK mode may use Cafe's broad auto-allow path, and explicit upstream `permissions.ask` rules must continue to override even that path. This distinction is security-sensitive: conflating Auto with bypass disables the classifier without telling the user. - Fresh Claude sessions should let upstream `query()` allocate the session id. The official SDK docs mark `sessionId` as optional/default auto-generated and recommend capturing the durable id from `system` init or result messages before passing it back through `resume`; Cafe Code should therefore only pass `resume` when it has a durable resume cursor from a real Claude transcript. Do not generate arbitrary `sessionId` values for new long-lived AsyncIterable sessions, because current Claude Code can reject the first queued turn with "No conversation found with session ID" before the transcript exists. - Do not pass Claude SDK `resumeSessionAt` for ordinary follow-up turns. Upstream SDK types document `resumeSessionAt` as a specific assistant-message checkpoint, not as the normal latest-leaf resume pointer; always applying it to Cafe follow-ups can make current Claude Code reject valid transcripts with "No message found with message.uuid". Only use `resumeSessionAt` for an explicit user-visible rewind/fork-to-message operation with dedicated recovery handling. - Claude `resume` loads a local transcript from the resolved Claude home project directory for the active cwd, not from Cafe's UI state alone. Before passing a durable Cafe cursor through SDK `resume`, verify that `~/.claude/projects//.jsonl` exists in the resolved Claude home or can be copied from another Claude project directory. If the transcript is missing, drop the stale cursor and start a fresh upstream Claude session rather than sending a doomed `--resume` that fails before the user's turn starts. +- Claude project directory names must match the installed Agent SDK's `projectKey` algorithm: resolve the cwd, replace every non-ASCII-alphanumeric character with `-`, and when the encoded name exceeds 200 characters append the same deterministic signed-32-bit hash suffix used upstream. Replacing only path separators is incorrect on POSIX paths containing punctuation and creates invalid colon-bearing directory components on Windows, which prevents durable resume discovery. - A zero-turn Claude `error_during_execution` result is a pre-run failure, not a durable conversation turn. Do not replace a healthy resume session id with the failed result's new session id, do not increment the durable resume turn count for that failed turn, and repair stale Cafe cursors from local Claude transcript files when a stored assistant checkpoint identifies the real session. Recoverable provider lifecycle failures should be recorded as work-log/debug diagnostics rather than promoted to blocking toasts or thread-level fatal banners. - Claude permissions flow through SDK permission callbacks such as `canUseTool`; respect the supplied abort signal, request id, tool-use id, suggested permission updates, and deny/allow semantics. - Claude Agent SDK sessions persist by default unless configured otherwise. Resume/session identifiers must be treated as durable provider state, not UI state. @@ -361,7 +378,7 @@ If Claude behavior is unclear, check the official Claude Agent SDK docs, the ins - Keep provider command ledgers idempotent. Mutating provider daemon RPCs must carry command IDs when replay or retry is possible. - Avoid long SQLite transactions around provider I/O, process startup, network calls, or stream consumption. - Do not load entire chat histories or event stores on every token/tool call. Use bounded queries, projections, cursors, and summary snapshots. -- Usage statistics keep headline daily totals in `usage_stats_days` and output-token attribution in `usage_stats_token_breakdown_days`, keyed by local day, canonical provider driver, and effective model. Attribution must never persist `providerInstanceId` or another configured account identifier: multiple accounts using the same driver intentionally aggregate together. Resolve a missing model at most once per turn from the live session, update it on `model.rerouted`, and use the bounded `unknown` bucket when no trustworthy model is available; never add provider-session queries to the per-token hot path. Aggregate and attribution deltas must commit in one SQLite transaction. Migration 61 begins attribution prospectively because older aggregate rows cannot be backfilled truthfully, and the existing Usage API/UI remains aggregate-only until an explicit display change is requested. +- Usage statistics keep headline daily totals in `usage_stats_days` and output-token attribution in `usage_stats_token_breakdown_days`, keyed by local day, canonical provider driver, and effective model. Attribution must never persist or display `providerInstanceId` or another configured account identifier: multiple accounts using the same driver intentionally aggregate together. Resolve a missing model at most once per turn from the live session, update it on `model.rerouted`, and use the bounded `unknown` bucket when no trustworthy model is available; never add provider-session queries to the per-token hot path. Aggregate and attribution deltas must commit in one SQLite transaction. Migration 61 begins attribution prospectively because older aggregate rows cannot be backfilled truthfully. Hydrate lifetime provider/model totals once at service startup, serve them from memory through `usageStats.get`, and keep the model-cardinality payload out of the 10 Hz `subscribeUsageStats` stream. The Usage page may refresh that in-memory detail at a low cadence while mounted and must show pre-migration aggregate tokens as earlier unattributed usage rather than assigning them speculatively. - Keep provider daemon event journals bounded in SQLite as well as in memory. The persistent `provider_daemon_events` table is a restart/replay buffer for daemon/supervisor handoff, not an analytics warehouse; retaining unbounded provider events causes post-prompt latency and stale projection pressure that the Codex CLI does not have. Large inherited journals must be pruned by non-blocking journal maintenance after IPC/TCP readiness, because one-time migration deletes can exceed the desktop provider-daemon readiness timeout and leave bootstrap without a socket. Restart replay from a detached supervisor must be idempotent on provider runtime `eventId`, not local cursor, because supervisor cursors and daemon cursors are separate ownership domains. - SQLite `database is locked` is a real lifecycle/performance signal. Add diagnostics for the writer, SQL operation, busy timeout, and affected command path instead of only increasing timeouts. - Migrations must be deterministic, backward-safe where possible, and covered by tests. Reconciliation migrations should explain the exact stale state they repair. @@ -381,6 +398,7 @@ If Claude behavior is unclear, check the official Claude Agent SDK docs, the ins - The renderer should display backend/provider truth and should not synthesize terminal, running, or active-turn state that can conflict with orchestration projections. - Scroll-follow behavior should be tolerant of small gaps from the bottom and must not jump to the top when steer messages, late messages, or terminal markers arrive. +- Timeline scrolling has two explicit modes. While following the tail, live row measurements must keep the final working/message row pinned even when a bounded tool/work-log preview rotates or changes height; coalesce any post-layout correction to one animation frame. Upward wheel/touch/keyboard/scrollbar review intent must synchronously cancel pending tail corrections. While detached for review, disable tail following and enable both data-change and size-change visible-content anchoring so provider updates do not move the text being read. Data-change anchoring must remain disabled in follow mode because it can fight submit-time bottom pinning. - Message timelines must handle late provider events after completion without duplicating terminal banners or losing streamed content. - Per-thread detail subscriptions must apply snapshots and events monotonically by orchestration sequence. Events at or below the detail snapshot sequence are stale for that subscription and must not regress focused-thread session or turn state. - Recoverable non-transport subscription failures must use capped exponential backoff instead of retrying a permanently invalid snapshot four times per second. Receiving an early snapshot chunk does not by itself prove recovery, because the same subscription may fail on a later chunk or event; reset the failure ramp only after a quiet failure window. Transport reconnect handling remains independent. diff --git a/Start-CafeCode.ps1 b/Start-CafeCode.ps1 index f268f836..6142bf00 100644 --- a/Start-CafeCode.ps1 +++ b/Start-CafeCode.ps1 @@ -4,58 +4,105 @@ param( ) $ErrorActionPreference = "Stop" +$script:StartCafeCodeRepoRoot = $PSScriptRoot -$repo = Split-Path -Parent $MyInvocation.MyCommand.Path -$logDir = Join-Path $env:USERPROFILE ".cafe-code\launcher-logs" -$launcherLog = Join-Path $logDir "launcher.log" -$stdoutLog = Join-Path $logDir "desktop-start.stdout.log" -$stderrLog = Join-Path $logDir "desktop-start.stderr.log" +function Select-FirstApplicationPath { + param( + [AllowNull()] + [object[]]$Commands + ) -New-Item -ItemType Directory -Force -Path $logDir | Out-Null + foreach ($command in @($Commands)) { + if ($null -eq $command) { + continue + } -$node = Get-Command -Name "node.exe" -CommandType Application -ErrorAction SilentlyContinue -if ($null -eq $node) { - $node = Get-Command -Name "node" -CommandType Application -ErrorAction SilentlyContinue -} -if ($null -eq $node) { - throw "Node.js 24.13.1 or newer in the Node 24 release line was not found on PATH." -} + $path = $command.Path + if (-not [string]::IsNullOrWhiteSpace($path)) { + return $path + } + } -$nodeVersionText = (& $node.Source --version).Trim().TrimStart("v") -$nodeVersion = [Version]$nodeVersionText -if ($nodeVersion.Major -ne 24 -or $nodeVersion -lt [Version]"24.13.1") { - throw "Cafe Code requires Node.js ^24.13.1; found $nodeVersionText at $($node.Source)." + return $null } -# The current dev build defaults local HTTPS on. Source installs on Windows do -# not always have OpenSSL available on PATH, so only disable backend HTTPS when -# the helper the backend uses to mint the local certificate is not discoverable. -# Use CommandType Application so aliases/functions cannot spoof this readiness -# check; if OpenSSL exists, let the normal desktop settings/exposure flow decide. -$openssl = Get-Command -Name "openssl.exe" -CommandType Application -ErrorAction SilentlyContinue -if ($null -eq $openssl) { - $openssl = Get-Command -Name "openssl" -CommandType Application -ErrorAction SilentlyContinue +function Resolve-FirstApplicationPath { + param( + [string[]]$Names + ) + + foreach ($name in $Names) { + # GitHub Windows runners can expose more than one matching Node application + # on PATH (for example actions/setup-node plus the preinstalled Node path). + # PowerShell returns both entries, so select a single executable path before + # probing the version or launching the desktop process. + $path = Select-FirstApplicationPath -Commands ( + Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue + ) + if (-not [string]::IsNullOrWhiteSpace($path)) { + return $path + } + } + + return $null } -if ($null -eq $openssl) { - $env:CAFE_CODE_HTTPS_ENABLED = "false" - "OpenSSL was not found on PATH; starting Cafe Code with local backend HTTPS disabled." | - Add-Content -LiteralPath $launcherLog -} else { - "OpenSSL was found on PATH; preserving Cafe Code local backend HTTPS defaults." | - Add-Content -LiteralPath $launcherLog +function Invoke-StartCafeCode { + param( + [switch]$Wait, + [string[]]$DesktopArgs = @() + ) + + $repo = $script:StartCafeCodeRepoRoot + $logDir = Join-Path $env:USERPROFILE ".cafe-code\launcher-logs" + $launcherLog = Join-Path $logDir "launcher.log" + $stdoutLog = Join-Path $logDir "desktop-start.stdout.log" + $stderrLog = Join-Path $logDir "desktop-start.stderr.log" + + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + + $nodePath = Resolve-FirstApplicationPath -Names @("node.exe", "node") + if ([string]::IsNullOrWhiteSpace($nodePath)) { + throw "Node.js 24.13.1 or newer in the Node 24 release line was not found on PATH." + } + + $nodeVersionText = (& $nodePath --version).Trim().TrimStart("v") + $nodeVersion = [Version]$nodeVersionText + if ($nodeVersion.Major -ne 24 -or $nodeVersion -lt [Version]"24.13.1") { + throw "Cafe Code requires Node.js ^24.13.1; found $nodeVersionText at $nodePath." + } + + # The current dev build defaults local HTTPS on. Source installs on Windows do + # not always have OpenSSL available on PATH, so only disable backend HTTPS when + # the helper the backend uses to mint the local certificate is not discoverable. + # Use CommandType Application so aliases/functions cannot spoof this readiness + # check; if OpenSSL exists, let the normal desktop settings/exposure flow decide. + $opensslPath = Resolve-FirstApplicationPath -Names @("openssl.exe", "openssl") + + if ([string]::IsNullOrWhiteSpace($opensslPath)) { + $env:CAFE_CODE_HTTPS_ENABLED = "false" + "OpenSSL was not found on PATH; starting Cafe Code with local backend HTTPS disabled." | + Add-Content -LiteralPath $launcherLog + } else { + "OpenSSL was found on PATH; preserving Cafe Code local backend HTTPS defaults." | + Add-Content -LiteralPath $launcherLog + } + + $desktopProcess = Start-Process ` + -FilePath $nodePath ` + -ArgumentList (@("apps/desktop/scripts/start-electron.mjs") + $DesktopArgs) ` + -WorkingDirectory $repo ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -WindowStyle Hidden ` + -PassThru + + if ($Wait) { + $desktopProcess.WaitForExit() + exit $desktopProcess.ExitCode + } } -$desktopProcess = Start-Process ` - -FilePath $node.Source ` - -ArgumentList (@("apps/desktop/scripts/start-electron.mjs") + $DesktopArgs) ` - -WorkingDirectory $repo ` - -RedirectStandardOutput $stdoutLog ` - -RedirectStandardError $stderrLog ` - -WindowStyle Hidden ` - -PassThru - -if ($Wait) { - $desktopProcess.WaitForExit() - exit $desktopProcess.ExitCode +if ($MyInvocation.InvocationName -ne ".") { + Invoke-StartCafeCode -Wait:$Wait -DesktopArgs $DesktopArgs } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a691e7ab..45cda8c1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/desktop", - "version": "0.0.51", + "version": "0.1.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/desktop/scripts/start-electron.test.mjs b/apps/desktop/scripts/start-electron.test.mjs index 27b121ca..8e34446d 100644 --- a/apps/desktop/scripts/start-electron.test.mjs +++ b/apps/desktop/scripts/start-electron.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { join, resolve } from "node:path"; import { describe, it } from "vitest"; @@ -11,6 +12,14 @@ import { resolveLaunchPlan, } from "./start-electron.mjs"; +// These launcher tests execute on all CI hosts. Build fixture paths with the +// host path implementation because the production launcher receives paths +// from that same host, even when a test selects the Linux display branch. +const fixtureRepositoryRoot = resolve("/repo"); +const fixtureDesktopDir = join(fixtureRepositoryRoot, "apps", "desktop"); +const fixtureServerEntrypoint = join(fixtureRepositoryRoot, "apps", "server", "dist", "bin.mjs"); +const fixtureWorkspaceDir = resolve("/workspace/project"); + describe("start-electron launcher", () => { it("detects whether Linux has a graphical display available", () => { assert.equal(hasDisplayServer({}, "linux"), false); @@ -41,33 +50,33 @@ describe("start-electron launcher", () => { args: ["--cafe-debug", "--port", "3888"], environment: {}, platform: "linux", - runtimeDesktopDir: "/repo/apps/desktop", - cwd: "/repo/apps/desktop", + runtimeDesktopDir: fixtureDesktopDir, + cwd: fixtureDesktopDir, electronPath: () => "/electron", - serverEntrypoint: "/repo/apps/server/dist/bin.mjs", + serverEntrypoint: fixtureServerEntrypoint, }); assert.equal(plan.type, "headless-server"); assert.equal(plan.command, process.execPath); assert.deepEqual(plan.args, [ - "/repo/apps/server/dist/bin.mjs", + fixtureServerEntrypoint, "serve", "--mode", "desktop", "--port", "3888", ]); - assert.equal(plan.cwd, "/repo"); + assert.equal(plan.cwd, fixtureRepositoryRoot); }); it("preserves the original invocation directory for headless server cwd when available", () => { assert.equal( resolveHeadlessServerCwd({ - cwd: "/repo/apps/desktop", - environment: { INIT_CWD: "/workspace/project" }, - runtimeDesktopDir: "/repo/apps/desktop", + cwd: fixtureDesktopDir, + environment: { INIT_CWD: fixtureWorkspaceDir }, + runtimeDesktopDir: fixtureDesktopDir, }), - "/workspace/project", + fixtureWorkspaceDir, ); }); @@ -76,28 +85,28 @@ describe("start-electron launcher", () => { args: ["--user-arg"], environment: { DISPLAY: ":0" }, platform: "linux", - runtimeDesktopDir: "/repo/apps/desktop", - cwd: "/repo/apps/desktop", + runtimeDesktopDir: fixtureDesktopDir, + cwd: fixtureDesktopDir, electronPath: () => "/electron", - serverEntrypoint: "/repo/apps/server/dist/bin.mjs", + serverEntrypoint: fixtureServerEntrypoint, }); assert.equal(plan.type, "electron"); assert.equal(plan.command, "/electron"); assert.deepEqual(plan.args, ["dist-electron/main.cjs", "--user-arg"]); - assert.equal(plan.cwd, "/repo/apps/desktop"); + assert.equal(plan.cwd, fixtureDesktopDir); }); it("resolves the staged or source server entrypoint next to the desktop runtime", () => { assert.equal( resolveHeadlessServerEntrypoint( - "/repo/apps/desktop", - (path) => path === "/repo/apps/server/dist/bin.mjs", + fixtureDesktopDir, + (path) => path === fixtureServerEntrypoint, ), - "/repo/apps/server/dist/bin.mjs", + fixtureServerEntrypoint, ); assert.equal( - resolveHeadlessServerEntrypoint("/repo/apps/desktop", () => false), + resolveHeadlessServerEntrypoint(fixtureDesktopDir, () => false), undefined, ); }); diff --git a/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts b/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts new file mode 100644 index 00000000..5c915f75 --- /dev/null +++ b/apps/desktop/src/app/DesktopUpdateDetectionProbe.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + collectDesktopUpdateDetectionProbeResult, + isDesktopUpdateDetectionProbeEnabled, + type DesktopUpdateDetectionProbeDependencies, +} from "./DesktopUpdateDetectionProbe.ts"; + +function makeDependencies( + overrides: Partial = {}, +): DesktopUpdateDetectionProbeDependencies { + return { + platform: "linux", + arch: "x64", + isPackaged: true, + currentVersion: "1.0.0-nightly.20260721.0", + expectedVersion: "1.0.0-nightly.20260721.1", + channel: "nightly", + checkForUpdates: async () => ({ + updateAvailable: true, + availableVersion: "1.0.0-nightly.20260721.1", + }), + ...overrides, + }; +} + +describe("DesktopUpdateDetectionProbe", () => { + it("enables only for the explicit detection switch", () => { + expect(isDesktopUpdateDetectionProbeEnabled(["Cafe Code"])).toBe(false); + expect( + isDesktopUpdateDetectionProbeEnabled(["Cafe Code", "--cafe-update-detection-probe"]), + ).toBe(true); + }); + + it("accepts the exact newer release reported by the updater", async () => { + await expect( + collectDesktopUpdateDetectionProbeResult(makeDependencies()), + ).resolves.toMatchObject({ + ok: true, + updateAvailable: true, + availableVersion: "1.0.0-nightly.20260721.1", + failure: null, + }); + }); + + it("fails closed for source runs, no update, wrong versions, and updater errors", async () => { + await expect( + collectDesktopUpdateDetectionProbeResult(makeDependencies({ isPackaged: false })), + ).resolves.toMatchObject({ ok: false, failure: "not-packaged" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ + checkForUpdates: async () => ({ updateAvailable: false, availableVersion: "1.0.0" }), + }), + ), + ).resolves.toMatchObject({ ok: false, failure: "no-update" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ expectedVersion: "1.0.0-nightly.20260721.2" }), + ), + ).resolves.toMatchObject({ ok: false, failure: "unexpected-version" }); + await expect( + collectDesktopUpdateDetectionProbeResult( + makeDependencies({ + checkForUpdates: async () => { + throw new Error("secret feed URL must not escape"); + }, + }), + ), + ).resolves.toEqual( + expect.not.objectContaining({ message: expect.stringContaining("secret feed URL") }), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts b/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts new file mode 100644 index 00000000..12e54803 --- /dev/null +++ b/apps/desktop/src/app/DesktopUpdateDetectionProbe.ts @@ -0,0 +1,227 @@ +import { writeFile } from "node:fs/promises"; + +import { autoUpdater } from "electron-updater"; +import * as Electron from "electron"; + +export const DESKTOP_UPDATE_DETECTION_PROBE_SWITCH = "--cafe-update-detection-probe"; +export const DESKTOP_UPDATE_DETECTION_RESULT_ENV = "CAFE_CODE_UPDATE_DETECTION_RESULT"; +export const DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV = + "CAFE_CODE_UPDATE_DETECTION_EXPECT_VERSION"; +export const DESKTOP_UPDATE_DETECTION_CHANNEL_ENV = "CAFE_CODE_UPDATE_DETECTION_CHANNEL"; +export const DESKTOP_UPDATE_DETECTION_FEED_URL_ENV = "CAFE_CODE_UPDATE_DETECTION_FEED_URL"; +export const DESKTOP_UPDATE_DETECTION_OUTPUT_PREFIX = "CAFE_CODE_UPDATE_DETECTION="; + +const UPDATE_CHECK_TIMEOUT_MS = 60_000; + +export type DesktopUpdateDetectionFailure = + | "check-failed" + | "invalid-channel" + | "not-packaged" + | "no-update" + | "result-file" + | "unexpected-version"; + +export interface DesktopUpdateDetectionProbeDependencies { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly isPackaged: boolean; + readonly currentVersion: string; + readonly expectedVersion: string | null; + readonly channel: "latest" | "nightly" | null; + readonly checkForUpdates: () => Promise<{ + readonly updateAvailable: boolean; + readonly availableVersion: string | null; + }>; +} + +export interface DesktopUpdateDetectionProbeResult { + readonly ok: boolean; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly isPackaged: boolean; + readonly channel: "latest" | "nightly" | null; + readonly currentVersion: string; + readonly expectedVersion: string | null; + readonly updateAvailable: boolean; + readonly availableVersion: string | null; + readonly failure: DesktopUpdateDetectionFailure | null; +} + +export function isDesktopUpdateDetectionProbeEnabled( + argv: readonly string[] = process.argv, +): boolean { + return argv.includes(DESKTOP_UPDATE_DETECTION_PROBE_SWITCH); +} + +function resolveUpdateChannel(rawChannel: string | undefined): "latest" | "nightly" | null { + const channel = rawChannel?.trim() || "latest"; + return channel === "latest" || channel === "nightly" ? channel : null; +} + +function baseProbeResult( + dependencies: DesktopUpdateDetectionProbeDependencies, +): Omit< + DesktopUpdateDetectionProbeResult, + "ok" | "updateAvailable" | "availableVersion" | "failure" +> { + return { + platform: dependencies.platform, + arch: dependencies.arch, + isPackaged: dependencies.isPackaged, + channel: dependencies.channel, + currentVersion: dependencies.currentVersion, + expectedVersion: dependencies.expectedVersion, + }; +} + +export async function collectDesktopUpdateDetectionProbeResult( + dependencies: DesktopUpdateDetectionProbeDependencies, +): Promise { + const base = baseProbeResult(dependencies); + if (!dependencies.isPackaged) { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "not-packaged", + }; + } + if (!dependencies.channel) { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "invalid-channel", + }; + } + + let updateAvailable = false; + let availableVersion: string | null = null; + try { + const result = await dependencies.checkForUpdates(); + updateAvailable = result.updateAvailable; + availableVersion = result.availableVersion; + } catch { + return { + ...base, + ok: false, + updateAvailable: false, + availableVersion: null, + failure: "check-failed", + }; + } + + if (!updateAvailable || !availableVersion) { + return { + ...base, + ok: false, + updateAvailable, + availableVersion, + failure: "no-update", + }; + } + if (dependencies.expectedVersion && availableVersion !== dependencies.expectedVersion) { + return { + ...base, + ok: false, + updateAvailable, + availableVersion, + failure: "unexpected-version", + }; + } + + return { + ...base, + ok: true, + updateAvailable, + availableVersion, + failure: null, + }; +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("update check timed out")), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error instanceof Error ? error : new Error("update check failed")); + }, + ); + }); +} + +async function makeRealDependencies(): Promise { + const channel = resolveUpdateChannel(process.env[DESKTOP_UPDATE_DETECTION_CHANNEL_ENV]); + const expectedVersion = process.env[DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV]?.trim() || null; + + return { + platform: process.platform, + arch: process.arch, + isPackaged: Electron.app.isPackaged, + currentVersion: Electron.app.getVersion(), + expectedVersion, + channel, + checkForUpdates: async () => { + await Electron.app.whenReady(); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + if (channel) { + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = channel === "nightly"; + autoUpdater.allowDowngrade = false; + } + const feedUrl = process.env[DESKTOP_UPDATE_DETECTION_FEED_URL_ENV]?.trim(); + if (feedUrl) { + autoUpdater.setFeedURL({ provider: "generic", url: feedUrl }); + } + const result = await withTimeout(autoUpdater.checkForUpdates(), UPDATE_CHECK_TIMEOUT_MS); + return { + updateAvailable: result?.isUpdateAvailable === true, + availableVersion: result?.updateInfo.version ?? null, + }; + }, + }; +} + +export async function runDesktopUpdateDetectionProbeAndExit(): Promise { + let result: DesktopUpdateDetectionProbeResult; + try { + result = await collectDesktopUpdateDetectionProbeResult(await makeRealDependencies()); + } catch { + result = { + ok: false, + platform: process.platform, + arch: process.arch, + isPackaged: Electron.app.isPackaged, + channel: resolveUpdateChannel(process.env[DESKTOP_UPDATE_DETECTION_CHANNEL_ENV]), + currentVersion: Electron.app.getVersion(), + expectedVersion: process.env[DESKTOP_UPDATE_DETECTION_EXPECT_VERSION_ENV]?.trim() || null, + updateAvailable: false, + availableVersion: null, + failure: "check-failed", + }; + } + + const resultPath = process.env[DESKTOP_UPDATE_DETECTION_RESULT_ENV]?.trim(); + if (resultPath) { + try { + await writeFile(resultPath, `${JSON.stringify(result)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + } catch { + result = { ...result, ok: false, failure: "result-file" }; + } + } + + console.info(`${DESKTOP_UPDATE_DETECTION_OUTPUT_PREFIX}${JSON.stringify(result)}`); + Electron.app.exit(result.ok ? 0 : 1); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index beecd692..bf7959a1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,6 +46,10 @@ import { isDesktopRuntimeSelfTestEnabled, runDesktopRuntimeSelfTestAndExit, } from "./app/DesktopRuntimeSelfTest.ts"; +import { + isDesktopUpdateDetectionProbeEnabled, + runDesktopUpdateDetectionProbeAndExit, +} from "./app/DesktopUpdateDetectionProbe.ts"; startStartupCpuProfiler({ role: "desktop-main" }); @@ -142,7 +146,9 @@ const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( ), ); -if (isDesktopRuntimeSelfTestEnabled()) { +if (isDesktopUpdateDetectionProbeEnabled()) { + void runDesktopUpdateDetectionProbeAndExit(); +} else if (isDesktopRuntimeSelfTestEnabled()) { void runDesktopRuntimeSelfTestAndExit(); } else { DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5ab5198b..0dd1449e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -28,6 +28,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { + getAutoUpdateDisabledReason, + resolveUnsignedDesktopUpdateInstallMode, +} from "./updateEligibility.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -43,11 +47,6 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; -const DESKTOP_UPDATES_DISABLED_REASON = "Update checks are disabled in this Cafe Code build."; - -function areDesktopUpdatesDisabledInThisBuild(): boolean { - return true; -} const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -135,7 +134,12 @@ function createBaseUpdateState( environment: DesktopEnvironment.DesktopEnvironmentShape, ): DesktopUpdateState { return { - ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), + ...createInitialDesktopUpdateState( + environment.appVersion, + environment.runtimeInfo, + channel, + resolveUnsignedDesktopUpdateInstallMode(environment.platform), + ), enabled, status: enabled ? "idle" : "disabled", }; @@ -163,33 +167,6 @@ function shouldBroadcastDownloadProgress( return nextStep !== previousStep || nextPercent === 100; } -function getAutoUpdateDisabledReason(args: { - isDevelopment: boolean; - isPackaged: boolean; - platform: NodeJS.Platform; - appImage?: string | undefined; - disabledByEnv: boolean; - hasUpdateFeedConfig: boolean; -}): string | null { - if (areDesktopUpdatesDisabledInThisBuild()) { - return DESKTOP_UPDATES_DISABLED_REASON; - } - - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } - if (args.isDevelopment || !args.isPackaged) { - return "Automatic updates are only available in packaged production builds."; - } - if (args.disabledByEnv) { - return "Automatic updates are disabled by the CAFE_CODE_DISABLE_AUTO_UPDATE setting."; - } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; - } - return null; -} - function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } @@ -215,6 +192,7 @@ const make = Effect.gen(function* () { environment.appVersion, environment.runtimeInfo, environment.defaultDesktopSettings.updateChannel, + resolveUnsignedDesktopUpdateInstallMode(environment.platform), ), ); @@ -335,6 +313,7 @@ const make = Effect.gen(function* () { if ( !(yield* Ref.get(updaterConfiguredRef)) || (yield* Ref.get(updateDownloadInFlightRef)) || + state.installMode !== "in-app" || state.status !== "available" ) { return { accepted: false, completed: false }; @@ -368,6 +347,7 @@ const make = Effect.gen(function* () { if ( (yield* Ref.get(desktopState.quitting)) || !(yield* Ref.get(updaterConfiguredRef)) || + state.installMode !== "in-app" || state.status !== "downloaded" ) { return { accepted: false, completed: false }; diff --git a/apps/desktop/src/updates/updateEligibility.test.ts b/apps/desktop/src/updates/updateEligibility.test.ts new file mode 100644 index 00000000..7e8bb1c4 --- /dev/null +++ b/apps/desktop/src/updates/updateEligibility.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + getAutoUpdateDisabledReason, + resolveUnsignedDesktopUpdateInstallMode, + type DesktopUpdateEligibilityInput, +} from "./updateEligibility.ts"; + +const eligibleInput: DesktopUpdateEligibilityInput = { + isDevelopment: false, + isPackaged: true, + platform: "win32", + disabledByEnv: false, + hasUpdateFeedConfig: true, +}; + +describe("desktop update eligibility", () => { + it("enables packaged Windows, macOS, and AppImage builds with a feed", () => { + expect(getAutoUpdateDisabledReason(eligibleInput)).toBeNull(); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, platform: "darwin" })).toBeNull(); + expect( + getAutoUpdateDisabledReason({ + ...eligibleInput, + platform: "linux", + appImage: "/opt/Cafe-Code.AppImage", + }), + ).toBeNull(); + }); + + it("keeps source builds, opt-outs, missing feeds, and non-AppImage Linux disabled", () => { + expect(getAutoUpdateDisabledReason({ ...eligibleInput, hasUpdateFeedConfig: false })).toContain( + "no update feed", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, isDevelopment: true })).toContain( + "packaged production builds", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, isPackaged: false })).toContain( + "packaged production builds", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, disabledByEnv: true })).toContain( + "CAFE_CODE_DISABLE_AUTO_UPDATE", + ); + expect(getAutoUpdateDisabledReason({ ...eligibleInput, platform: "linux" })).toContain( + "AppImage", + ); + }); + + it("uses manual installation only for unsigned macOS artifacts", () => { + expect(resolveUnsignedDesktopUpdateInstallMode("darwin")).toBe("manual"); + expect(resolveUnsignedDesktopUpdateInstallMode("win32")).toBe("in-app"); + expect(resolveUnsignedDesktopUpdateInstallMode("linux")).toBe("in-app"); + }); +}); diff --git a/apps/desktop/src/updates/updateEligibility.ts b/apps/desktop/src/updates/updateEligibility.ts new file mode 100644 index 00000000..0ef663e9 --- /dev/null +++ b/apps/desktop/src/updates/updateEligibility.ts @@ -0,0 +1,34 @@ +import type { DesktopUpdateInstallMode } from "@cafecode/contracts"; + +export interface DesktopUpdateEligibilityInput { + readonly isDevelopment: boolean; + readonly isPackaged: boolean; + readonly platform: NodeJS.Platform; + readonly appImage?: string | undefined; + readonly disabledByEnv: boolean; + readonly hasUpdateFeedConfig: boolean; +} + +export function getAutoUpdateDisabledReason(args: DesktopUpdateEligibilityInput): string | null { + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; + } + if (args.isDevelopment || !args.isPackaged) { + return "Automatic updates are only available in packaged production builds."; + } + if (args.disabledByEnv) { + return "Automatic updates are disabled by the CAFE_CODE_DISABLE_AUTO_UPDATE setting."; + } + if (args.platform === "linux" && !args.appImage) { + return "Automatic updates on Linux require running the AppImage build."; + } + return null; +} + +export function resolveUnsignedDesktopUpdateInstallMode( + platform: NodeJS.Platform, +): DesktopUpdateInstallMode { + // Squirrel.Mac rejects unsigned in-place updates. Keep detection available, but direct + // users to the release DMG until Cafe has an Apple Developer ID signing identity. + return platform === "darwin" ? "manual" : "in-app"; +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index e2f0519d..b918b250 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -20,6 +20,15 @@ const runtimeInfo = { } as const; describe("updateMachine", () => { + it("records the platform install mode in initial state", () => { + expect(createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest").installMode).toBe( + "in-app", + ); + expect( + createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest", "manual").installMode, + ).toBe("manual"); + }); + it("clears transient errors when a check starts", () => { const state = reduceDesktopUpdateStateOnCheckStart( { diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 098effe3..cc083eb7 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateInstallMode, DesktopUpdateState, } from "@cafecode/contracts"; @@ -18,11 +19,13 @@ export function createInitialDesktopUpdateState( currentVersion: string, runtimeInfo: DesktopRuntimeInfo, channel: DesktopUpdateChannel, + installMode: DesktopUpdateInstallMode = "in-app", ): DesktopUpdateState { return { enabled: false, status: "disabled", channel, + installMode, currentVersion, hostArch: runtimeInfo.hostArch, appArch: runtimeInfo.appArch, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index fd14b1d1..de9e283a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -92,6 +92,10 @@ const initializeGitWorkspace = Effect.fn(function* (cwd: string) { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); + // Checkpoint contents are byte-level test fixtures. Disable the Windows + // global autocrlf policy so checkout/revert does not rewrite committed LF + // bytes and turn a VCS correctness assertion into a runner preference test. + runGit(cwd, ["config", "core.autocrlf", "false"]); const fileSystem = yield* FileSystem.FileSystem; const { join } = yield* Path.Path; yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n"); diff --git a/apps/server/package.json b/apps/server/package.json index 9cdc8eb6..601d8e66 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@cafeai/cafe-code", - "version": "0.0.51", + "version": "0.1.0", "description": "A minimal AI chat harness for coding agents.", "keywords": [ "agent", @@ -46,7 +46,7 @@ "test:e2e:provider-daemon": "vitest run --config vitest.e2e.config.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.215", + "@anthropic-ai/claude-agent-sdk": "0.3.216", "@anthropic-ai/sdk": "^0.98.0", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index 30b5a9d3..2b90e362 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -218,9 +218,11 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); - expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( - true, - ); + expect( + chmodCalls.some( + (call) => call.mode === 0o700 && call.path.replaceAll("\\", "/").endsWith("/secrets"), + ), + ).toBe(true); expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 151d386d..2f5c3795 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -81,10 +81,13 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), - ); + const fd = + process.platform === "win32" + ? NFS.openSync(filePath, "r") + : yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertSome(payload, { @@ -107,6 +110,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // Pretend this ordinary temp file fd has the same fstat shape so the test // verifies that bootstrap does not attempt the blocking `/dev/fd/` // duplication path for socket descriptors. + // This path deliberately bypasses descriptor duplication, so the + // bootstrap stream becomes the sole descriptor owner just as it does + // for inherited child-process pipes in production. const fd = NFS.openSync(filePath, "r"); openSyncInterceptor.socketLikeFd = fd; openSyncInterceptor.openedPaths = []; @@ -136,10 +142,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); - // Open without acquireRelease: the direct-stream fallback uses autoClose: true, - // so the stream owns the fd lifecycle and closes it asynchronously on end. - // Attempting to also close it synchronously in a finalizer races with the - // stream's async close and produces an uncaught EBADF. + // Open without acquireRelease: the direct-stream fallback uses + // autoClose, so the stream owns this descriptor after handoff. const fd = NFS.openSync(filePath, "r"); openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; @@ -156,7 +160,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { - const fd = NFS.openSync("/dev/null", "r"); + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-unavailable-" }); + const fd = NFS.openSync(filePath, "r"); NFS.closeSync(fd); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); @@ -164,40 +170,42 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); - it.effect("returns none when the bootstrap read times out before any value arrives", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = path.join(tempDir, "bootstrap.pipe"); - - yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); - - const _writer = yield* Effect.acquireRelease( - Effect.sync(() => - spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { - stdio: ["ignore", "ignore", "ignore"], - }), - ), - (writer) => - Effect.sync(() => { - writer.kill("SIGKILL"); - }), - ); + it.effect.skipIf(process.platform === "win32")( + "returns none when the bootstrap read times out before any value arrives", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); + const fifoPath = path.join(tempDir, "bootstrap.pipe"); + + yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + + const _writer = yield* Effect.acquireRelease( + Effect.sync(() => + spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + stdio: ["ignore", "ignore", "ignore"], + }), + ), + (writer) => + Effect.sync(() => { + writer.kill("SIGKILL"); + }), + ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), - ); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); - const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.forkScoped); + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(100)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); - const payload = yield* Fiber.join(fiber); - assertNone(payload); - }).pipe(Effect.provide(TestClock.layer())), + const payload = yield* Fiber.join(fiber); + assertNone(payload); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index d23dcb3a..a99f417a 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -36,16 +36,31 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function input: stream, crlfDelay: Infinity, }); + let completed = false; const cleanup = () => { + // `Effect.callback` invokes this canceler after a successful resume as + // well as on interruption. Once a line/close/error has settled the + // callback, let the ReadStream finish its normal EOF auto-close. Calling + // destroy in parallel with that close can issue a second close/read on + // the descriptor and surface an uncaught EBADF. + if (completed) { + return; + } stream.removeListener("error", handleError); input.removeListener("line", handleLine); input.removeListener("close", handleClose); input.close(); - stream.destroy(); + // The bootstrap stream owns its descriptor after handoff. On + // interruption, destroy initiates that stream's auto-close; the + // completed guard above keeps this path from racing normal EOF teardown. + if (!stream.destroyed && !stream.closed) { + stream.destroy(); + } }; const handleError = (error: Error) => { + completed = true; if (isUnavailableBootstrapFdError(error)) { resume(Effect.succeedNone); return; @@ -61,6 +76,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function }; const handleLine = (line: string) => { + completed = true; const parsed = decodeJsonResult(schema)(line); if (Result.isSuccess(parsed)) { resume(Effect.succeedSome(parsed.success)); @@ -77,6 +93,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function }; const handleClose = () => { + completed = true; resume(Effect.succeedNone); }; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 511af782..b25bb690 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,5 @@ import NodeOS from "node:os"; +import { openSync } from "node:fs"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -51,6 +52,12 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); const encoded = yield* encodeDesktopBootstrap(payload); yield* fs.writeFileString(filePath, `${encoded}\n`); + if (process.platform === "win32") { + // Windows cannot duplicate an inherited descriptor through + // `/proc/self/fd` or `/dev/fd`; readBootstrapEnvelope therefore owns + // and closes the direct descriptor after this explicit handoff. + return openSync(filePath, "r"); + } const { fd } = yield* fs.open(filePath, { flag: "r" }); return fd; }); @@ -344,8 +351,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = "/tmp/t3-bootstrap-home"; + const { join, resolve } = yield* Path.Path; + // Bootstrap homes are resolved before they enter ServerConfig. Resolve + // the fixture through the host path service too, so `/tmp` acquires the + // current drive on Windows while remaining unchanged on POSIX. + const baseDir = resolve("/tmp/t3-bootstrap-home"); const fd = yield* openBootstrapFd( makeDesktopBootstrap({ port: 4888, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9634c50a..d18edfb1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { GitCommandError, GitPreparePullRequestThreadInput, @@ -364,9 +365,41 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const fileSystem = yield* FileSystem.FileSystem; - const canonicalizeExistingPath = (value: string) => + const resolveCanonicalExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); - const normalizeStatusCacheKey = canonicalizeExistingPath; + + const resolveExistingPathIdentity = (value: string) => + Effect.gen(function* () { + const canonicalPath = yield* resolveCanonicalExistingPath(value); + + if (process.platform !== "win32") { + return canonicalPath; + } + + // A Windows directory can be reported through a short 8.3 alias, a + // long path, different separator styles, or different casing. String + // equality can therefore mistake the main checkout for a second + // worktree and either reuse the wrong branch or overwrite it. Prefer + // the filesystem identity Git is actually referring to. Fall back to a + // normalized path only when the entry disappears between Git's listing + // and this lookup. + const info = yield* fileSystem.stat(canonicalPath).pipe(Effect.option); + if (Option.isSome(info) && Option.isSome(info.value.ino) && info.value.ino.value !== 0) { + return `win32-file:${info.value.dev}:${info.value.ino.value}`; + } + + const normalizedPath = canonicalPath + .replace(/^\\\\\?\\/, "") + .replaceAll("\\", "/") + .toLowerCase(); + return `win32-path:${normalizedPath}`; + }); + // Cache loaders receive their key as the cwd they query. Keep that key a + // valid filesystem path: the Windows identity above is intentionally only + // for equality checks and must never escape into a Git subprocess as cwd. + // realPath still coalesces ordinary aliases for cache purposes while + // preserving a path that Node and Git can open on every platform. + const normalizeStatusCacheKey = resolveCanonicalExistingPath; const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -645,7 +678,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const rootWorktreePath = yield* resolveExistingPathIdentity(input.cwd); const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, @@ -720,7 +753,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { continue; } - const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + const worktreePath = yield* resolveExistingPathIdentity(branch.worktreePath); if (worktreePath !== rootWorktreePath) { return branch; } @@ -731,7 +764,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + ? yield* resolveExistingPathIdentity(existingBranchBeforeFetch.worktreePath) : null; if ( existingBranchBeforeFetch?.worktreePath && @@ -759,7 +792,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + ? yield* resolveExistingPathIdentity(existingBranchAfterFetch.worktreePath) : null; if ( existingBranchAfterFetch?.worktreePath && diff --git a/apps/server/src/httpsCertificate.test.ts b/apps/server/src/httpsCertificate.test.ts index 3e596677..73bdb20d 100644 --- a/apps/server/src/httpsCertificate.test.ts +++ b/apps/server/src/httpsCertificate.test.ts @@ -4,7 +4,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ensureHttpsCertificateMaterial, resolveOpenSslExecutable } from "./httpsCertificate.ts"; +import { + ensureHttpsCertificateMaterial, + openSslGenerationTimeoutMs, + resolveOpenSslExecutable, +} from "./httpsCertificate.ts"; it.layer(NodeServices.layer)("ensureHttpsCertificateMaterial", (it) => { it.effect("resolves Git for Windows OpenSSL when it is installed outside PATH", () => @@ -42,6 +46,14 @@ it.layer(NodeServices.layer)("ensureHttpsCertificateMaterial", (it) => { }), ); + it.effect("allows slower certificate generation only on Windows", () => + Effect.sync(() => { + assert.equal(openSslGenerationTimeoutMs("win32"), 60_000); + assert.equal(openSslGenerationTimeoutMs("linux"), 20_000); + assert.equal(openSslGenerationTimeoutMs("darwin"), 20_000); + }), + ); + it.effect("generates and reuses self-signed certificate material", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/httpsCertificate.ts b/apps/server/src/httpsCertificate.ts index 345979d3..bb3361e7 100644 --- a/apps/server/src/httpsCertificate.ts +++ b/apps/server/src/httpsCertificate.ts @@ -17,6 +17,8 @@ const execFileAsync = promisify(execFile); const CERT_VALID_DAYS = 397; const CERT_RENEWAL_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; const OPENSSL_EXECUTABLE_NAME = "openssl"; +const OPENSSL_GENERATION_TIMEOUT_MS = 20_000; +const WINDOWS_OPENSSL_GENERATION_TIMEOUT_MS = 60_000; export interface HttpsCertificateMaterial { readonly cert: string; @@ -119,6 +121,9 @@ export const resolveOpenSslExecutable = async ( return OPENSSL_EXECUTABLE_NAME; }; +export const openSslGenerationTimeoutMs = (platform: NodeJS.Platform = process.platform): number => + platform === "win32" ? WINDOWS_OPENSSL_GENERATION_TIMEOUT_MS : OPENSSL_GENERATION_TIMEOUT_MS; + const certificateIsFresh = async (certPath: string, keyPath: string): Promise => { try { const [certPem, keyPem] = await Promise.all([ @@ -184,7 +189,8 @@ const generateCertificate = async (input: { "-out", tempCertPath, ], - { timeout: 20_000 }, + // Git for Windows OpenSSL can be substantially slower under parallel CI load. + { timeout: openSslGenerationTimeoutMs() }, ); await fs.chmod(tempKeyPath, 0o600); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 58418dc6..21d17980 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -253,6 +253,7 @@ function createGitRepository() { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); + runGit(cwd, ["config", "core.autocrlf", "false"]); fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index eb620290..fda5c275 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,6 +12,7 @@ import { type ProjectId, type OrchestrationSession, type OrchestrationThread, + type ProviderInteractionMode, ThreadId, type ProviderSession, type RuntimeMode, @@ -697,7 +698,7 @@ const make = Effect.gen(function* () { readonly project?: OrchestrationProjectShell; readonly activeSession?: ProviderSession | undefined; readonly activeSessionResolved?: boolean; - readonly interactionMode?: "default" | "plan"; + readonly interactionMode?: ProviderInteractionMode; }, ) { const thread = options?.thread ?? (yield* resolveThread(threadId)); @@ -988,7 +989,7 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly interactionMode?: "default" | "plan"; + readonly interactionMode?: ProviderInteractionMode; readonly createdAt: string; readonly thread?: OrchestrationThread; readonly project?: OrchestrationProjectShell; diff --git a/apps/server/src/persistence/Services/UsageStats.ts b/apps/server/src/persistence/Services/UsageStats.ts index 0fd1792c..423429e4 100644 --- a/apps/server/src/persistence/Services/UsageStats.ts +++ b/apps/server/src/persistence/Services/UsageStats.ts @@ -13,8 +13,8 @@ import { NonNegativeInt, ProviderDriverKind, - TrimmedNonEmptyString, UsageStatsDayKey, + UsageStatsModel, } from "@cafecode/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -30,14 +30,6 @@ export const UsageStatsDayRow = Schema.Struct({ }); export type UsageStatsDayRow = typeof UsageStatsDayRow.Type; -/** - * Provider model identifiers originate outside Cafe. Bound their persisted - * size so a hostile or malformed runtime event cannot create unbounded SQLite - * index keys. The service maps invalid/missing values to a short sentinel. - */ -export const UsageStatsModel = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); -export type UsageStatsModel = typeof UsageStatsModel.Type; - export const UsageStatsTokenBreakdownDayRow = Schema.Struct({ day: UsageStatsDayKey, provider: ProviderDriverKind, @@ -60,8 +52,8 @@ export interface UsageStatsRepositoryShape { /** * List provider/model output-token attribution ascending by day and stable - * key order. This is deliberately not loaded by the aggregate usage service - * or exposed through the current Usage UI. + * key order. The usage service hydrates these rows once and aggregates them + * in memory; opening Settings must not query SQLite. */ readonly listTokenBreakdownDays: Effect.Effect< ReadonlyArray, diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 42cb4c50..cc10522f 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -12,6 +12,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + type CommandAvailabilityProbe, isCommandAvailable, launchBrowser, launchEditorProcess, @@ -25,6 +26,11 @@ import { resolveTerminalProcessLaunch, } from "./externalLauncher.ts"; +function commandsAvailable(...commands: ReadonlyArray): CommandAvailabilityProbe { + const available = new Set(commands); + return (command) => available.has(command); +} + function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); for (let index = 0; index < input.length; index += 1) { @@ -454,15 +460,12 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("falls back to zeditor when zed is not installed", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); + const result = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "zed" }, + "linux", + { PATH: "" }, + commandsAvailable("zeditor"), + ); assert.deepEqual(result, { command: "zeditor", @@ -519,21 +522,11 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("prefers Dolphin for the file-manager editor in KDE sessions", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); - - yield* fs.writeFileString(path.join(dir, "dolphin"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "gio"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "dolphin"), 0o755); - yield* fs.chmod(path.join(dir, "gio"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - const launch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", - { PATH: dir, XDG_CURRENT_DESKTOP: "KDE" }, + { PATH: "", XDG_CURRENT_DESKTOP: "KDE" }, + commandsAvailable("dolphin", "gio", "xdg-open"), ); assert.deepEqual(launch, { @@ -545,19 +538,11 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("uses gio open for the Linux file-manager editor when available outside KDE", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); - - yield* fs.writeFileString(path.join(dir, "gio"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "gio"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - const launch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", - { PATH: dir, XDG_CURRENT_DESKTOP: "GNOME" }, + { PATH: "", XDG_CURRENT_DESKTOP: "GNOME" }, + commandsAvailable("gio", "xdg-open"), ); assert.deepEqual(launch, { @@ -968,19 +953,12 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { ); it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); + Effect.sync(() => { + const editors = resolveAvailableEditors( + "linux", + { PATH: "" }, + commandsAvailable("zeditor", "xdg-open"), + ); assert.deepEqual(editors, ["zed", "file-manager"]); }), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index b595a0bf..7c097ff0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -57,6 +57,11 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +export type CommandAvailabilityProbe = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -139,9 +144,10 @@ function resolveEditorArgs( function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): Option.Option { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (commandAvailable(command, options)) { return Option.some(command); } } @@ -234,6 +240,7 @@ function fileManagerLaunchForPlatform( target: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): EditorLaunch { switch (platform) { case "darwin": @@ -246,10 +253,10 @@ function fileManagerLaunchForPlatform( // (for example org.kde.dolphin.desktop). In some source-launch // environments that id is then treated as an executable name, so prefer // the concrete Dolphin binary when Cafe is running in a KDE session. - if (isKdeDesktop(env) && isCommandAvailable("dolphin", { platform, env })) { + if (isKdeDesktop(env) && commandAvailable("dolphin", { platform, env })) { return { command: "dolphin", args: [target] }; } - if (isCommandAvailable("gio", { platform, env })) { + if (commandAvailable("gio", { platform, env })) { return { command: "gio", args: ["open", target] }; } } @@ -288,19 +295,20 @@ export function resolveBrowserLaunch( export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): ReadonlyArray { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const { command } = fileManagerLaunchForPlatform("", platform, env); - if (isCommandAvailable(command, { platform, env })) { + const { command } = fileManagerLaunchForPlatform("", platform, env, commandAvailable); + if (commandAvailable(command, { platform, env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = resolveAvailableCommand(editor.commands, { platform, env }, commandAvailable); if (Option.isSome(command)) { available.push(editor.id); } @@ -378,6 +386,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + commandAvailable: CommandAvailabilityProbe = isCommandAvailable, ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, @@ -391,7 +400,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + resolveAvailableCommand(editorDef.commands, { platform, env }, commandAvailable), () => editorDef.commands[0], ); return { @@ -404,7 +413,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); } - return fileManagerLaunchForPlatform(input.cwd, platform, env); + return fileManagerLaunchForPlatform(input.cwd, platform, env, commandAvailable); }); export function resolveEditorProcessLaunch( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4..1eb3853c 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -52,6 +52,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver; + const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "index.html", ''); yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); @@ -59,7 +60,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe(path.join(cwd, "public", "brand", "logo.svg")); }), ); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 7862e67e..b113829f 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -16,8 +16,6 @@ import { repositoryIdentityFromRemoteOutput, } from "./RepositoryIdentityResolver.ts"; -const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); - const git = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; @@ -53,15 +51,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); - const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizePathSeparators(resolvedIdentityRoot)).toBe( - normalizePathSeparators(resolvedRepoRoot), - ); + const [identityInfo, repoInfo] = yield* Effect.all([ + fileSystem.stat(identity?.rootPath ?? ""), + fileSystem.stat(repoRoot), + ]); + // Windows can spell the same directory through both its short 8.3 name + // and its long name. Device/inode identity verifies the repository + // contract without conflating spelling with filesystem identity. + expect(identityInfo.dev).toBe(repoInfo.dev); + expect(identityInfo.ino).toEqual(repoInfo.ino); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ad5fb713..6732fc72 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -102,6 +102,15 @@ const withInstanceIdentity = ...(input.accentColor ? { accentColor: input.accentColor } : {}), ...(input.authActions ? { authActions: input.authActions } : {}), continuation: { groupKey: input.continuationGroupKey }, + // The renderer chooses between a non-interrupting `thread.turn.steer` + // and the legacy interrupt-then-send fallback from this public snapshot, + // not from the daemon-only adapter object. Keep the snapshot aligned with + // `makeClaudeAdapter().capabilities.liveSteer`: Claude's streaming-input + // protocol accepts UUID-stamped messages on the live AsyncIterable and + // does not require Query.interrupt(). Omitting this field silently falls + // back to "unsupported" at the contract decoder and turns a nudge into a + // destructive interrupt even though the adapter can queue it safely. + runtimeCapabilities: { ...snapshot.runtimeCapabilities, liveSteer: "supported" }, }); export const ClaudeDriver: ProviderDriver = { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 01508b0b..34c9e798 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -22,6 +22,7 @@ * @module provider/Drivers/CodexDriver */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@cafecode/contracts"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -35,7 +36,11 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexCliProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexCliProviderStatus, + makePendingCodexProvider, + readCodexAccountRateLimits, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -55,10 +60,11 @@ import { const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -// Keep account usage reasonably fresh without using the heavy app-server -// metadata path. The Codex status probe intentionally stays on the cheap -// `codex --version` / `codex login status` / redacted usage-request path, so -// this periodic refresh does not create hidden Codex app-server sessions. +// Periodically refresh installation/authentication truth without using the +// heavy app-server metadata path. Full refreshes are single-flight, while +// prompt-triggered usage updates below use only the redacted HTTP request, so +// neither path creates hidden Codex app-server sessions or repeated CLI probe +// queues. const PERIODIC_SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const UPDATE_DEFINITION = { provider: DRIVER_KIND, @@ -253,6 +259,32 @@ export const CodexDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingCodexProvider(settings).pipe(Effect.map(stampIdentity)), checkProvider, + // Prompt sends need fresh rate-limit metadata, not another pair of + // `codex --version` / `codex login status` subprocesses. Upstream + // Codex obtains this data from BackendClient's account usage request; + // use the same bounded, redacted HTTP path against the effective + // shadow home and leave full health/auth checks on the five-minute and + // explicit manual-refresh paths. + refreshAccountUsage: ({ settings, snapshot }) => { + if (snapshot.auth.status !== "authenticated" || snapshot.auth.type !== "chatgpt") { + return Effect.succeed(undefined); + } + return refreshCodexShadowHome.pipe( + Effect.catch((cause) => + Effect.logWarning("codex.home.authRefreshBeforeUsageFailed", { + instanceId, + detail: cause.message, + }), + ), + Effect.andThen(DateTime.now), + Effect.map(DateTime.formatIso), + Effect.flatMap((checkedAt) => + readCodexAccountRateLimits(settings, effectiveEnvironment, checkedAt), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + }, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 43075092..3e82d8df 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -41,6 +41,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { + claudeProjectDirectoryName, + encodeClaudeProjectDirectoryName, makeClaudeAdapter, resolveClaudeModelSessionOptions, type ClaudeAdapterLiveOptions, @@ -327,12 +329,22 @@ function promptMessageText(message: SDKUserMessage | undefined): string | undefi } function claudeProjectDirectoryForTest(homePath: string, cwd: string): string { - return path.join(homePath, ".claude", "projects", path.resolve(cwd).replaceAll(path.sep, "-")); + return path.join(homePath, ".claude", "projects", claudeProjectDirectoryName(path, cwd)); } const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); +describe("Claude project directory encoding", () => { + it("matches upstream punctuation replacement and bounded long-path hashing", () => { + assert.equal( + encodeClaudeProjectDirectoryName(String.raw`C:\Users\mike\work.dir`), + "C--Users-mike-work-dir", + ); + assert.equal(encodeClaudeProjectDirectoryName("a".repeat(201)), `${"a".repeat(200)}-rkvsv5`); + }); +}); + describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); @@ -423,6 +435,33 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("starts classifier-backed Claude auto mode without enabling bypass", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + interactionMode: "auto", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "work autonomously", + interactionMode: "auto", + attachments: [], + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.deepEqual(harness.query.setPermissionModeCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -774,6 +813,236 @@ describe("ClaudeAdapterLive", () => { ); assert.equal(promptMessageText(messages[0]), "first prompt"); assert.equal(promptMessageText(messages[1]), "follow-up while active"); + // Claude Code's interactive correction path is a streamed user message, + // not an interrupt followed by a replacement turn. A regression here + // would cancel the running tool, collapse Cafe's work log, and reset the + // active-turn timer before Claude had incorporated the correction. + assert.deepEqual(harness.query.interruptCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps one Cafe turn active across a queued Claude follow-up", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "finish the original task", + attachments: [], + }); + yield* adapter.steerTurn({ + threadId: session.threadId, + expectedTurnId: turn.turnId, + input: "incorporate this follow-up", + attachments: [], + }); + + const messages = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 2), + ); + const firstMessageUuid = messages[0]?.uuid; + const followUpMessageUuid = messages[1]?.uuid; + assert.isString(firstMessageUuid); + assert.isString(followUpMessageUuid); + if (firstMessageUuid === undefined || followUpMessageUuid === undefined) { + throw new Error("Expected UUID-stamped Claude prompts."); + } + + harness.query.emit({ + type: "system", + subtype: "init", + capabilities: ["msg_lifecycle_v1"], + session_id: "claude-session-follow-up", + uuid: "init-follow-up", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: firstMessageUuid, + state: "started", + session_id: "claude-session-follow-up", + uuid: "lifecycle-first-started", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "queued", + session_id: "claude-session-follow-up", + uuid: "lifecycle-follow-up-queued", + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "claude-session-follow-up", + uuid: "assistant-first-segment", + parent_tool_use_id: null, + message: { + id: "assistant-message-first-segment", + content: [{ type: "text", text: "Original segment complete." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["transient first-segment failure"], + session_id: "claude-session-follow-up", + uuid: "result-first-segment", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const activeSessions = yield* adapter.listSessions(); + assert.equal(activeSessions[0]?.status, "running"); + assert.equal(activeSessions[0]?.activeTurnId, turn.turnId); + assert.equal((yield* adapter.readThread(session.threadId)).turns.length, 0); + + // The real CLI emits the result before retiring the completed command, + // then immediately promotes the queued UUID. These lifecycle frames must + // not create a second synthetic Cafe turn or close the original turn. + harness.query.emit({ + type: "command_lifecycle", + command_uuid: firstMessageUuid, + state: "completed", + session_id: "claude-session-follow-up", + uuid: "lifecycle-first-completed", + } as unknown as SDKMessage); + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "started", + session_id: "claude-session-follow-up", + uuid: "lifecycle-follow-up-started", + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "claude-session-follow-up", + uuid: "assistant-follow-up-segment", + parent_tool_use_id: null, + message: { + id: "assistant-message-follow-up-segment", + content: [{ type: "text", text: "Follow-up incorporated." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-follow-up", + uuid: "result-follow-up-segment", + user_message_uuid: followUpMessageUuid, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const completedSessions = yield* adapter.listSessions(); + assert.equal(completedSessions[0]?.status, "ready"); + assert.equal(completedSessions[0]?.activeTurnId, undefined); + const snapshot = yield* adapter.readThread(session.threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.id, turn.turnId); + assert.equal(snapshot.turns[0]?.items.length, 2); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("completes one Cafe turn for a coalesced Claude follow-up batch", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first batch message", + attachments: [], + }); + yield* adapter.steerTurn({ + threadId: session.threadId, + expectedTurnId: turn.turnId, + input: "coalesced follow-up", + attachments: [], + }); + + const messages = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 2), + ); + const firstMessageUuid = messages[0]?.uuid; + const followUpMessageUuid = messages[1]?.uuid; + if (firstMessageUuid === undefined || followUpMessageUuid === undefined) { + throw new Error("Expected UUID-stamped Claude prompts."); + } + + // Claude can dequeue several queued UUIDs into one model turn. Both + // lifecycle entries become started before the shared assistant/result + // segment, and the non-representative UUID completes afterward. + for (const [commandUuid, uuid] of [ + [firstMessageUuid, "lifecycle-coalesced-first-started"], + [followUpMessageUuid, "lifecycle-coalesced-follow-up-started"], + ] as const) { + harness.query.emit({ + type: "command_lifecycle", + command_uuid: commandUuid, + state: "started", + session_id: "claude-session-coalesced-follow-up", + uuid, + } as unknown as SDKMessage); + } + harness.query.emit({ + type: "assistant", + session_id: "claude-session-coalesced-follow-up", + uuid: "assistant-coalesced-follow-up", + parent_tool_use_id: null, + message: { + id: "assistant-message-coalesced-follow-up", + content: [{ type: "text", text: "Both messages handled together." }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-coalesced-follow-up", + uuid: "result-coalesced-follow-up", + user_message_uuid: firstMessageUuid, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + assert.equal((yield* adapter.listSessions())[0]?.status, "running"); + + harness.query.emit({ + type: "command_lifecycle", + command_uuid: followUpMessageUuid, + state: "completed", + session_id: "claude-session-coalesced-follow-up", + uuid: "lifecycle-coalesced-follow-up-completed", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const completedSessions = yield* adapter.listSessions(); + assert.equal(completedSessions[0]?.status, "ready"); + assert.equal(completedSessions[0]?.activeTurnId, undefined); + const snapshot = yield* adapter.readThread(session.threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.id, turn.turnId); + assert.equal(snapshot.turns[0]?.items.length, 1); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -882,6 +1151,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not close an active user turn when sendTurn is called directly", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const activeTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "keep this turn running", + attachments: [], + }); + const secondSendExit = yield* Effect.exit( + adapter.sendTurn({ + threadId: session.threadId, + input: "this must use the queued follow-up path", + attachments: [], + }), + ); + + assert.equal(secondSendExit._tag, "Failure"); + if (secondSendExit._tag === "Failure") { + assert.include(String(secondSendExit.cause), "turn/steer"); + } + + const activeSessions = yield* adapter.listSessions(); + assert.equal(activeSessions[0]?.status, "running"); + assert.equal(activeSessions[0]?.activeTurnId, activeTurn.turnId); + assert.equal((yield* adapter.readThread(session.threadId)).turns.length, 0); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -3732,6 +4039,59 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("surfaces an Auto-mode fallback prompt instead of bypassing the classifier", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + interactionMode: "auto", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "deploy-production" }, + { + signal: new AbortController().signal, + toolUseID: "tool-auto-fallback-1", + requestId: "permission-auto-fallback-1", + }, + ); + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "request.opened") { + return; + } + + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.make(runtimeRequestId), + "decline", + ); + yield* Stream.runHead(adapter.streamEvents); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "deny"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4517,12 +4877,23 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* adapter.sendTurn({ + const firstTurn = yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection, attachments: [], }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-same-model", + uuid: "result-same-model-first-turn", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", @@ -4549,7 +4920,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* adapter.sendTurn({ + const firstTurn = yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection: createModelSelection( @@ -4559,6 +4930,17 @@ describe("ClaudeAdapterLive", () => { ), attachments: [], }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "claude-session-model-change", + uuid: "result-model-change-first-turn", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", @@ -4604,6 +4986,51 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("switches an established Claude session into Auto mode through the SDK", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + interactionMode: "default", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "inspect this", + interactionMode: "default", + attachments: [], + }); + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-auto-transition", + uuid: "result-auto-transition", + user_message_uuid: firstTurn.turnId, + } as unknown as SDKMessage); + yield* Fiber.join(turnCompletedFiber); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "continue in auto mode", + interactionMode: "auto", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, ["auto"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([ { runtimeMode: "full-access", expectedBase: "bypassPermissions" }, { runtimeMode: "approval-required", expectedBase: "default" }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 87e77ee0..3eae7161 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -30,6 +30,7 @@ import { type ModelSelection, type ProviderApprovalDecision, ProviderDriverKind, + type ProviderInteractionMode, ProviderInstanceId, ProviderItemId, type ProviderRuntimeEvent, @@ -40,6 +41,7 @@ import { type ProviderSteerTurnInput, type ProviderUserInputAnswers, type RuntimeSessionState, + type RuntimeMode, type RuntimeContentStreamKind, RuntimeItemId, RuntimeRequestId, @@ -93,6 +95,33 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJ const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("claudeAgent"); + +function runtimeModeToClaudePermissionMode(runtimeMode: RuntimeMode): PermissionMode | undefined { + switch (runtimeMode) { + case "approval-required": + // Omitting the initial flag lets Claude Code use its standard/manual + // permission behavior and matches the Agent SDK's documented default. + return undefined; + case "auto-accept-edits": + return "acceptEdits"; + case "full-access": + return "bypassPermissions"; + } +} + +function resolveClaudePermissionMode(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly basePermissionMode: PermissionMode | undefined; +}): PermissionMode | undefined { + // Claude Code 2.1.216 calls the standard UI state "manual", while the Agent + // SDK control protocol continues to spell it `default`. Plan and Auto are + // real SDK modes, not prompt decorations or aliases for Cafe access policy. + if (input.interactionMode === "plan" || input.interactionMode === "auto") { + return input.interactionMode; + } + return input.basePermissionMode; +} + type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -193,6 +222,13 @@ interface ClaudeResumeState { interface ClaudeTurnState { readonly turnId: TurnId; readonly startedAt: string; + /** + * User turns may only be extended through `steerTurn`; a second `sendTurn` + * must never silently terminalize them. Synthetic turns are the exception: + * they represent provider-initiated background output that arrived between + * user prompts and may be closed before the next explicit user turn starts. + */ + readonly origin: "user" | "synthetic"; readonly items: Array; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; @@ -213,6 +249,12 @@ interface ClaudeTurnState { nextSyntheticAssistantBlockIndex: number; } +interface ClaudeDeferredTurnResult { + readonly status: ProviderRuntimeTurnStatus; + readonly result: SDKResultMessage; + readonly errorMessage?: string; +} + interface AssistantTextBlockState { readonly itemId: string; readonly blockIndex: number; @@ -272,6 +314,7 @@ interface ClaudeSessionContext { readonly promptLifecycleByUuid: Map; readonly capabilities: Set; turnState: ClaudeTurnState | undefined; + deferredTurnResult: ClaudeDeferredTurnResult | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; @@ -283,7 +326,7 @@ interface ClaudeSessionContext { interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; - // The 0.3.209 runtime implements this control request, but its public Query + // The 0.3.216 runtime implements this control request, but its public Query // interface has not exposed the method yet. Keep it optional for older SDKs. readonly cancelAsyncMessage?: (messageUuid: string) => Promise; readonly setModel: (model?: string) => Promise; @@ -383,6 +426,43 @@ function isTerminalClaudeCommandLifecycleState(state: ClaudeCommandLifecycleStat return state === "completed" || state === "cancelled" || state === "discarded"; } +function claudeResultUserMessageUuid(result: SDKResultMessage): string | undefined { + return trimmedStringValue((result as Record).user_message_uuid); +} + +/** + * Retire the Cafe-owned input represented by a Claude result. + * + * Agent SDK 0.3.216 correlates successful results with `user_message_uuid`. + * Older CLIs and error results can omit that field, so the compatibility path + * retires the oldest input Claude has acknowledged as started. A final fallback + * handles pre-2.1.206 runtimes that do not emit command lifecycle frames at all. + * Map insertion order is the provider input order and is never reconstructed + * from prompt text, keeping this boundary content-blind and deterministic. + */ +function consumeClaudeResultPrompt( + context: ClaudeSessionContext, + result: SDKResultMessage, +): string | undefined { + const correlatedUuid = claudeResultUserMessageUuid(result); + if (correlatedUuid !== undefined) { + context.promptLifecycleByUuid.delete(correlatedUuid); + return correlatedUuid; + } + + const started = Array.from(context.promptLifecycleByUuid).find( + ([, state]) => state === "started", + ); + const fallback = started ?? context.promptLifecycleByUuid.entries().next().value; + if (fallback === undefined) { + return undefined; + } + + const [messageUuid] = fallback; + context.promptLifecycleByUuid.delete(messageUuid); + return messageUuid; +} + function claudeTaskTerminalStatus(value: unknown): "completed" | "failed" | "stopped" | undefined { const status = trimmedStringValue(value)?.toLowerCase(); if (status === "completed" || status === "failed" || status === "stopped") { @@ -854,8 +934,38 @@ function isDurableClaudeResumeState( return Boolean(resumeState.resumeSessionAt) || (resumeState.turnCount ?? 0) > 0; } -function claudeProjectDirectoryName(path: Path.Path, cwd: string): string { - return path.resolve(cwd).replaceAll(path.sep, "-"); +const CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT = 200; + +function claudeProjectDirectoryHash(value: string): string { + // Claude Code uses the conventional signed 32-bit JavaScript string hash + // here. Keep the bitwise truncation explicit so long-path transcript lookup + // remains byte-for-byte compatible with the upstream CLI on every host. + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(36); +} + +/** + * Encode an absolute cwd using Claude Code's project-directory algorithm. + * + * Agent SDK 0.3.215's `projectKey` implementation replaces every + * non-alphanumeric character, not only the host path separator, and bounds + * long names with a deterministic hash suffix. Besides matching upstream, + * replacing the Windows volume colon prevents an invalid `projects/C:...` + * child path from escaping the intended directory component. + */ +export function encodeClaudeProjectDirectoryName(resolvedCwd: string): string { + const encoded = resolvedCwd.replace(/[^a-zA-Z0-9]/g, "-"); + if (encoded.length <= CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT) { + return encoded; + } + return `${encoded.slice(0, CLAUDE_PROJECT_DIRECTORY_PREFIX_LIMIT)}-${claudeProjectDirectoryHash(resolvedCwd)}`; +} + +export function claudeProjectDirectoryName(path: Pick, cwd: string): string { + return encodeClaudeProjectDirectoryName(path.resolve(cwd)); } function resolveClaudeConfigDirectory(path: Path.Path, env: NodeJS.ProcessEnv): string { @@ -1326,10 +1436,12 @@ const CLAUDE_EXECUTION_DIAGNOSTIC_PREFIX = "[ede_diagnostic]"; function makeClaudeTurnState(input: { readonly turnId: TurnId; readonly startedAt: string; + readonly origin: "user" | "synthetic"; }): ClaudeTurnState { return { turnId: input.turnId, startedAt: input.startedAt, + origin: input.origin, items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], @@ -2378,11 +2490,74 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); + const finalizeTurnSegment = Effect.fn("finalizeTurnSegment")(function* ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + result?: SDKResultMessage, + ) { + const turnState = context.turnState; + if (!turnState) { + return; + } + + for (const [index, tool] of context.inFlightTools.entries()) { + const toolStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: toolStamp.eventId, + provider: PROVIDER, + createdAt: toolStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: status === "completed" ? "completed" : "failed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: tool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), + raw: { + source: "claude.sdk.message", + method: "claude/result", + payload: result ?? { status }, + }, + }); + context.inFlightTools.delete(index); + } + // Clear any remaining stale entries (e.g. from interrupted content blocks). + context.inFlightTools.clear(); + + for (const block of turnState.assistantTextBlockOrder) { + yield* completeAssistantTextBlock(context, block, { + force: true, + rawMethod: "claude/result", + rawPayload: result ?? { status }, + }); + } + + // A streaming-input query emits one result per dequeued user-message batch. + // A Cafe steer can therefore produce another assistant response with block + // index zero while the same Cafe turn remains active. Retain accumulated + // turn items, diagnostics, and the canonical turn id, but reset all state + // whose identifiers are scoped to one Claude response segment. + turnState.assistantTextBlocks.clear(); + turnState.assistantTextBlockOrder.splice(0); + turnState.nextSyntheticAssistantBlockIndex = -1; + }); + const completeTurn = Effect.fn("completeTurn")(function* ( context: ClaudeSessionContext, status: ProviderRuntimeTurnStatus, errorMessage?: string, result?: SDKResultMessage, + options?: { readonly segmentAlreadyFinalized?: boolean }, ) { const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); const effectiveContextWindow = @@ -2449,46 +2624,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - for (const [index, tool] of context.inFlightTools.entries()) { - const toolStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: toolStamp.eventId, - provider: PROVIDER, - createdAt: toolStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: status === "completed" ? "completed" : "failed", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: { - toolName: tool.toolName, - input: tool.input, - }, - }, - providerRefs: nativeProviderRefs(context, { - providerItemId: tool.itemId, - }), - raw: { - source: "claude.sdk.message", - method: "claude/result", - payload: result ?? { status }, - }, - }); - context.inFlightTools.delete(index); - } - // Clear any remaining stale entries (e.g. from interrupted content blocks) - context.inFlightTools.clear(); - - for (const block of turnState.assistantTextBlockOrder) { - yield* completeAssistantTextBlock(context, block, { - force: true, - rawMethod: "claude/result", - rawPayload: result ?? { status }, - }); + if (options?.segmentAlreadyFinalized !== true) { + yield* finalizeTurnSegment(context, status, result); } const zeroTurnExecutionFailure = @@ -2531,6 +2668,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); const updatedAt = yield* nowIso; + context.deferredTurnResult = undefined; context.turnState = undefined; context.session = { ...context.session, @@ -2926,6 +3064,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.promptLifecycleByUuid.set(message.command_uuid, message.state); } + if (message.state === "started" && context.deferredTurnResult !== undefined) { + // A queued message promoted after the preceding result owns a new Claude + // response segment and will emit its own result. Retire the older deferred + // boundary so a later lifecycle-completed frame cannot close Cafe's turn + // before this newly started segment reports its terminal result. For a + // coalesced batch, every member is already `started` before the shared + // result arrives, so this branch does not discard the batch result. + context.deferredTurnResult = undefined; + } + + if ( + message.state === "completed" && + context.promptLifecycleByUuid.size === 0 && + context.deferredTurnResult !== undefined + ) { + // Claude can coalesce several queued user messages into one model turn. + // In that case a single result represents the batch while lifecycle + // frames retire every UUID. Complete only after the final tracked UUID + // reaches terminal state; the response segment was already finalized + // when its result arrived, so doing so again would duplicate item events. + const deferred = context.deferredTurnResult; + context.deferredTurnResult = undefined; + yield* completeTurn(context, deferred.status, deferred.errorMessage, deferred.result, { + segmentAlreadyFinalized: true, + }); + } + if (message.state === "discarded") { yield* Effect.logWarning("claude.commandLifecycle.discarded", { threadId: context.session.threadId, @@ -2977,6 +3142,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.turnState = makeClaudeTurnState({ turnId, startedAt, + origin: "synthetic", }); context.session = { ...context.session, @@ -3070,6 +3236,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status !== "completed" ? (resultPrimaryError(message) ?? resultErrors[0] ?? "Claude turn failed.") : undefined; + const completedPromptUuid = consumeClaudeResultPrompt(context, message); + const hasQueuedCafeInput = context.promptLifecycleByUuid.size > 0; if (status === "failed") { if (isZeroTurnClaudeExecutionFailure(message)) { @@ -3105,6 +3273,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: message, }, ); + } else if (hasQueuedCafeInput) { + // A result is scoped to one dequeued command/batch, not to the lifetime + // of the streaming query. Claude continues draining its input queue + // after recoverable execution errors, so keep this diagnostic in the + // work log and let the already-accepted follow-up continue. + yield* emitRuntimeWarning( + context, + "Claude response segment failed; an already-queued follow-up remains active.", + { + error: sanitizeDiagnosticLine(errorMessage ?? "Claude turn failed."), + pendingPromptCount: context.promptLifecycleByUuid.size, + }, + ); } else { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); } @@ -3117,6 +3298,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* notifyAuthStatusChanged(false); } + if (!authFailure && hasQueuedCafeInput) { + // Claude's stream-json queue emits a result for the response segment that + // just finished, then promotes the next queued SDKUserMessage without + // ending the process. This remains true for recoverable execution-error + // results. Preserve the same user-facing lifecycle Cafe uses for a Codex + // steer: one canonical turn stays active while the provider reaches a + // safe boundary and incorporates the follow-up. Finalize only segment- + // local stream/tool state here; a later result (or the terminal lifecycle + // frames of a coalesced batch) closes the canonical turn. + yield* finalizeTurnSegment(context, status, message); + context.deferredTurnResult = { + status, + result: message, + ...(errorMessage !== undefined ? { errorMessage } : {}), + }; + yield* Effect.logInfo("claude.followUp.resultBoundaryDeferred", { + threadId: context.session.threadId, + providerInstanceId: boundInstanceId, + completedPromptUuid: completedPromptUuid ?? "", + pendingPromptCount: context.promptLifecycleByUuid.size, + messageLifecycleAdvertised: context.capabilities.has("msg_lifecycle_v1"), + }); + return; + } + + context.deferredTurnResult = undefined; yield* completeTurn(context, status, errorMessage, message); if (authFailure) { yield* stopSessionInternal(context, { @@ -4246,7 +4453,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } - const runtimeMode = input.runtimeMode ?? "full-access"; const matchedAskRule = callbackOptions.matchedAskRule !== undefined; // Agent SDK 0.3.215 marks prompts forced by a user-configured // permissions.ask rule. That explicit rule is more specific than @@ -4254,7 +4460,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // of being silently auto-approved. Do not persist ruleContent here: it // can contain sensitive project policy and is not needed to honor the // upstream decision. - if (runtimeMode === "full-access" && !matchedAskRule) { + // Only true bypass mode may skip Cafe's callback. Auto mode can be + // layered over a historical full-access runtime policy, but upstream's + // classifier must remain authoritative. If Auto falls back to a human + // prompt, silently allowing it here would defeat the safety model. + if (context.currentPermissionMode === "bypassPermissions" && !matchedAskRule) { return { behavior: "allow", updatedInput: toolInput, @@ -4383,12 +4593,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const { apiModelId, selectedContextWindowTokens, effectiveEffort, settings } = resolveClaudeModelSessionOptions(modelSelection); const fastMode = settings.fastMode === true; - const runtimeModeToPermission: Record = { - "auto-accept-edits": "acceptEdits", - "full-access": "bypassPermissions", - }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; - const initialPermissionMode = input.interactionMode === "plan" ? "plan" : permissionMode; + const permissionMode = runtimeModeToClaudePermissionMode(input.runtimeMode); + const initialPermissionMode = resolveClaudePermissionMode({ + interactionMode: input.interactionMode, + basePermissionMode: permissionMode, + }); const claudeAdditionalDirectories = [ ...(input.cwd ? [input.cwd] : []), ...(input.additionalDirectories ?? []), @@ -4631,6 +4840,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( promptLifecycleByUuid: new Map(), capabilities: new Set(), turnState: undefined, + deferredTurnResult: undefined, lastKnownContextWindow: selectedContextWindowTokens, lastKnownTokenUsage: undefined, lastAssistantUuid: undefined, @@ -4725,9 +4935,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : undefined; if (context.turnState) { - // Auto-close a stale synthetic turn (from background agent responses - // between user prompts) to prevent blocking the user's next turn. - yield* completeTurn(context, "completed"); + if (context.turnState.origin === "synthetic") { + // Auto-close provider-initiated background output before beginning the + // user's next explicit turn. A real user turn is never eligible for + // this path because closing it would drop or split a queued follow-up. + yield* completeTurn(context, "completed"); + } else { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Claude turn '${context.turnState.turnId}' is already active; route additional input through turn/steer so Claude can queue it without interruption.`, + }); + } } if (modelSelection?.model) { @@ -4756,11 +4975,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // attached the first streamed user message can fail with "No message // found" / "No conversation found" on current Claude Agent SDK releases. const desiredPermissionMode = - input.interactionMode === "plan" - ? "plan" - : input.interactionMode === "default" - ? (context.basePermissionMode ?? "default") - : undefined; + input.interactionMode === undefined + ? undefined + : (resolveClaudePermissionMode({ + interactionMode: input.interactionMode, + basePermissionMode: context.basePermissionMode, + }) ?? "default"); if ( desiredPermissionMode !== undefined && desiredPermissionMode !== context.currentPermissionMode @@ -4792,6 +5012,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const turnState = makeClaudeTurnState({ turnId, startedAt: yield* nowIso, + origin: "user", }); turnState.promptTextBytes = Buffer.byteLength(buildPromptText(input, boundInstanceId), "utf8"); turnState.promptAttachmentCount = input.attachments?.length ?? 0; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 7c6b0140..8743858b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -654,7 +654,15 @@ async function fetchCodexAccountRateLimits(input: { } } -const readCodexAccountRateLimits = Effect.fn("readCodexAccountRateLimits")(function* ( +/** + * Read only the redacted ChatGPT account-usage snapshot used by Codex. + * + * This deliberately performs no Codex CLI or app-server process operation. + * Callers must continue to use the full provider-status path when they need + * installation, version, or authentication truth. Credentials stay inside + * this module and only the schema-bounded usage summary is returned. + */ +export const readCodexAccountRateLimits = Effect.fn("readCodexAccountRateLimits")(function* ( codexSettings: CodexSettings, environment: NodeJS.ProcessEnv, checkedAt: string, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 03eedab9..1cc2cbcf 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -669,6 +669,24 @@ describe("buildTurnStartParams", () => { }); }); + it("normalizes a persisted Claude auto mode when a thread switches to Codex", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto-accept-edits", + prompt: "Continue with Codex", + model: "gpt-5.3-codex", + interactionMode: "auto", + }), + ); + + assert.equal(params.collaborationMode?.mode, "default"); + assert.equal( + params.collaborationMode?.settings.developer_instructions, + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + ); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 37f58a75..dad549a3 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -749,14 +749,20 @@ function buildCodexCollaborationMode(input: { if (input.interactionMode === undefined) { return undefined; } + // `auto` is a Claude permission mode persisted in Cafe's provider-neutral + // thread state. A thread can later switch providers, but Codex app-server's + // collaboration protocol only accepts `default` and `plan`; conservatively + // translate every non-plan provider mode to Codex's normal collaboration + // mode instead of forwarding an invalid wire value. + const mode = input.interactionMode === "plan" ? "plan" : "default"; const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; return { - mode: input.interactionMode, + mode, settings: { model, reasoning_effort: input.effort ?? "medium", developer_instructions: - input.interactionMode === "plan" + mode === "plan" ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, }, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 5e6373aa..4280117c 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -34,6 +34,7 @@ import { } from "@cafecode/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; @@ -86,6 +87,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => Effect.gen(function* () { + const path = yield* Path.Path; const personalId = ProviderInstanceId.make("codex_personal"); const workId = ProviderInstanceId.make("codex_work"); const codexDriverKind = ProviderDriverKind.make("codex"); @@ -143,14 +145,16 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(personalSnapshot.driver).toBe(codexDriverKind); expect(personalSnapshot.enabled).toBe(false); expect(personalSnapshot.continuation?.groupKey).toBe( - "codex:home:/home/julius/.codex_personal", + `codex:home:${path.resolve("/home/julius/.codex_personal")}`, ); const workSnapshot = yield* work!.snapshot.getSnapshot; expect(workSnapshot.instanceId).toBe(workId); expect(workSnapshot.driver).toBe(codexDriverKind); expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(workSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); // Nothing goes to the unavailable bucket — both drivers are registered. const unavailable = yield* registry.listUnavailable; @@ -213,6 +217,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { it.live("boots one instance of every shipped driver from a single config map", () => Effect.gen(function* () { + const path = yield* Path.Path; const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); @@ -283,13 +288,24 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(codexSnapshot.instanceId).toBe(codexId); expect(codexSnapshot.driver).toBe(codexDriverKind); expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(codexSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); + expect(codexSnapshot.runtimeCapabilities?.liveSteer).toBe("supported"); const claudeSnapshot = yield* claude!.snapshot.getSnapshot; expect(claudeSnapshot.instanceId).toBe(claudeId); expect(claudeSnapshot.driver).toBe(claudeDriverKind); expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + expect(claudeSnapshot.continuation?.groupKey).toBe( + `claude:home:${path.resolve("/home/julius/.claude-work")}`, + ); + // The renderer reads this snapshot capability when deciding whether a + // running-turn follow-up is safe to steer. It cannot see the adapter's + // private capability object, so both surfaces must agree even for the + // initial disabled/pending snapshot before a CLI probe has completed. + expect(claudeSnapshot.runtimeCapabilities?.liveSteer).toBe("supported"); + expect(claude!.adapter.capabilities.liveSteer).toBe("supported"); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bae26824..da0b1784 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -899,6 +899,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T slashCommands: [], skills: [], } as const satisfies ServerProvider; + const usageRefreshedProvider = { + ...cachedProvider, + accountRateLimits: { + rateLimits: { + primary: { + usedPercent: 20, + windowDurationMins: 300, + resetsAt: 1_780_000_000, + }, + }, + checkedAt: "2026-04-29T10:01:00.000Z", + }, + } as const satisfies ServerProvider; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -915,6 +928,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), getSnapshot: Effect.succeed(cachedProvider), refresh: Effect.die(new Error("simulated refresh failure")), + refreshAccountUsage: Effect.succeed(usageRefreshedProvider), streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], @@ -952,6 +966,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ cachedProvider, ]); + assert.deepStrictEqual(yield* registry.refreshInstanceAccountUsage(codexInstanceId), [ + usageRefreshedProvider, + ]); }).pipe(Effect.provide(runtimeServices)); }), ); @@ -1257,7 +1274,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // but custom models are projected into error snapshots, so this // proves the aggregator no longer holds the initial snapshot. const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { + for (let attempts = 0; attempts < 120; attempts += 1) { const providers = yield* registry.getProviders; const codex = providers.find((provider) => provider.instanceId === "codex"); if ( @@ -1268,6 +1285,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T } yield* TestClock.adjust("50 millis"); yield* Effect.yieldNow; + if (process.platform === "win32") { + // The probe intentionally uses the real process spawner to + // observe ENOENT. Advancing TestClock cannot advance libuv's + // Windows process callback, so give that callback a bounded + // slice of wall time under the fully parallel CI workload. + yield* Effect.promise( + () => new Promise((resolve) => setTimeout(resolve, 25)), + ); + } } return yield* registry.getProviders; }); @@ -1886,7 +1912,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/t3code-claude-home"; const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1900,6 +1925,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); return Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.resolve("/tmp/t3code-claude-home"); const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index e76680e0..98d51c64 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -199,6 +199,7 @@ const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource driverKind: instance.driverKind, getSnapshot: instance.snapshot.getSnapshot, refresh: instance.snapshot.refresh, + refreshAccountUsage: instance.snapshot.refreshAccountUsage, streamChanges: instance.snapshot.streamChanges, }); @@ -549,6 +550,23 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const refreshInstanceAccountUsage = Effect.fn("refreshInstanceAccountUsage")(function* ( + instanceId: ProviderInstanceId, + ) { + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); + if (!providerSource?.refreshAccountUsage) { + return yield* Ref.get(providersRef); + } + return yield* providerSource.refreshAccountUsage.pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), + ), + ); + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { @@ -760,6 +778,8 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstanceAccountUsage: (instanceId: ProviderInstanceId) => + refreshInstanceAccountUsage(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, updateProviderAccountRateLimits, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 55b3e669..0ab9d898 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -233,7 +233,8 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { provider, capabilities: { sessionModelSwitch: "in-session", - liveSteer: provider === CODEX_DRIVER ? "supported" : "unsupported", + liveSteer: + provider === CODEX_DRIVER || provider === CLAUDE_AGENT_DRIVER ? "supported" : "unsupported", }, startSession, sendTurn, @@ -1116,6 +1117,44 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect( + "routes direct Claude sendTurn into its queued follow-up path while a turn is active", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude-active"), { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-claude-active"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + routing.claude.updateSession(session.threadId, (current) => ({ + ...current, + status: "running", + activeTurnId: asTurnId("turn-claude-active"), + })); + routing.claude.sendTurn.mockClear(); + routing.claude.steerTurn.mockClear(); + + const turn = yield* provider.sendTurn({ + threadId: session.threadId, + input: "queue this without interrupting Claude", + attachments: [], + }); + + assert.equal(turn.turnId, asTurnId("turn-claude-active")); + assert.equal(routing.claude.sendTurn.mock.calls.length, 0); + assert.equal(routing.claude.steerTurn.mock.calls.length, 1); + assert.deepEqual(routing.claude.steerTurn.mock.calls[0]?.[0], { + threadId: session.threadId, + expectedTurnId: asTurnId("turn-claude-active"), + input: "queue this without interrupting Claude", + }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 719d46bb..ef4ae6dc 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1059,7 +1059,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.kind": routed.adapter.provider, ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); - if (routed.adapter.provider === ProviderDriverKind.make("codex")) { + if (routed.adapter.capabilities.liveSteer === "supported") { + // Projection state can lag the provider runtime during long streams or + // reconnects. Ask the adapter for its live session before starting a + // turn, and route additional input through its supported steer/queue + // path whenever it still owns an active turn. This applies to Codex's + // native steer RPC and Claude's non-interrupting SDK input queue. const activeSessions = yield* routed.adapter.listSessions(); const activeSession = activeSessions.find((session) => session.threadId === input.threadId); if (activeSession?.status === "running" && activeSession.activeTurnId !== undefined) { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 698b6c06..ec550c19 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -49,6 +49,16 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Refresh only account-usage metadata for one provider instance. This is a + * no-op for providers without a usage-only capability and intentionally + * never falls back to the provider's full binary health/authentication + * probe. + */ + readonly refreshInstanceAccountUsage: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index aa404ef9..27b7bc8b 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -7,5 +7,11 @@ export interface ServerProviderShape { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + /** + * Refresh account-scoped usage metadata without executing the provider's + * binary health/authentication probes. Providers that do not expose a + * bounded usage-only path omit this capability. + */ + readonly refreshAccountUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; } diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 5531ee5e..4373c4c0 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -13,5 +13,6 @@ export type ProviderSnapshotSource = { readonly driverKind: ProviderDriverKind; readonly getSnapshot: ServerProviderShape["getSnapshot"]; readonly refresh: ServerProviderShape["refresh"]; + readonly refreshAccountUsage: ServerProviderShape["refreshAccountUsage"]; readonly streamChanges: Stream.Stream; }; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index dfd30e8c..9f88876d 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -88,6 +88,22 @@ const refreshedSnapshotSecond: ServerProvider = { message: "Refreshed provider availability again.", }; +const refreshedAccountRateLimits = { + rateLimits: { + primary: { + usedPercent: 35, + windowDurationMins: 300, + resetsAt: 1_780_000_000, + }, + secondary: { + usedPercent: 60, + windowDurationMins: 10_080, + resetsAt: 1_780_100_000, + }, + }, + checkedAt: "2026-04-10T00:00:05.000Z", +} as const; + const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, checkedAt: "2026-04-10T00:00:04.000Z", @@ -291,6 +307,7 @@ describe("makeManagedServerProvider", () => { Effect.scoped( Effect.gen(function* () { const checkCalls = yield* Ref.make(0); + const initialCheckStarted = yield* Deferred.make(); const releaseInitialCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ maintenanceCapabilities, @@ -299,6 +316,7 @@ describe("makeManagedServerProvider", () => { haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(initialCheckStarted, undefined).pipe(Effect.ignore)), Effect.flatMap((count) => count === 1 ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) @@ -308,7 +326,7 @@ describe("makeManagedServerProvider", () => { refreshInterval: null, }); - yield* Effect.yieldNow; + yield* Deferred.await(initialCheckStarted); assert.strictEqual(yield* Ref.get(checkCalls), 1); const initialUpdateFiber = yield* Stream.take(provider.streamChanges, 1).pipe( @@ -330,4 +348,109 @@ describe("makeManagedServerProvider", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("coalesces overlapping full refreshes into one provider check", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(checkStarted, undefined).pipe(Effect.ignore)), + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(checkStarted); + const refreshes = yield* Effect.all([provider.refresh, provider.refresh], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + + // The initial background probe owns the single flight. Both manual + // callers wait on it instead of queueing two more CLI checks behind it. + assert.strictEqual(yield* Ref.get(checkCalls), 1); + yield* Deferred.succeed(releaseCheck, undefined); + + assert.deepStrictEqual(yield* Fiber.join(refreshes), [ + refreshedSnapshot, + refreshedSnapshot, + ]); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); + + it.effect("coalesces usage-only refreshes without rerunning the full provider check", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const usageCalls = yield* Ref.make(0); + const usageStarted = yield* Deferred.make(); + const releaseUsage = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseInitialCheck)), + Effect.as(refreshedSnapshot), + ), + refreshAccountUsage: () => + Ref.update(usageCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(usageStarted, undefined).pipe(Effect.ignore)), + Effect.flatMap(() => Deferred.await(releaseUsage)), + Effect.as(refreshedAccountRateLimits), + ), + refreshInterval: "1 hour", + }); + + const initialUpdate = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseInitialCheck, undefined); + yield* Fiber.join(initialUpdate); + + const refreshAccountUsage = provider.refreshAccountUsage; + assert.isDefined(refreshAccountUsage); + if (!refreshAccountUsage) { + return; + } + + const usageRefreshes = yield* Effect.all([refreshAccountUsage, refreshAccountUsage], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(usageStarted); + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.strictEqual(yield* Ref.get(usageCalls), 1); + yield* Deferred.succeed(releaseUsage, undefined); + + const refreshed = yield* Fiber.join(usageRefreshes); + assert.deepStrictEqual(refreshed, [ + { ...refreshedSnapshot, accountRateLimits: refreshedAccountRateLimits }, + { ...refreshedSnapshot, accountRateLimits: refreshedAccountRateLimits }, + ]); + assert.strictEqual((yield* provider.getSnapshot).version, refreshedSnapshot.version); + assert.deepStrictEqual( + (yield* provider.getSnapshot).accountRateLimits, + refreshedAccountRateLimits, + ); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 7f6d4213..f926d261 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,4 +1,5 @@ -import type { ServerProvider } from "@cafecode/contracts"; +import type { ServerProvider, ServerProviderAccountRateLimits } from "@cafecode/contracts"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -17,6 +18,71 @@ interface ProviderSnapshotState { readonly enrichmentGeneration: number; } +interface SingleFlight { + readonly current: Effect.Effect | null>; + readonly run: (operation: Effect.Effect) => Effect.Effect; +} + +type SingleFlightAdmission = + | { readonly deferred: Deferred.Deferred; readonly leader: true } + | { readonly deferred: Deferred.Deferred; readonly leader: false }; + +/** + * Share one provider probe among every caller that arrives while that probe is + * running. A semaphore alone is insufficient here: it serializes duplicate + * work, which means an initial refresh, a periodic refresh, and a manual + * refresh can all execute back-to-back after one slow CLI invocation. + * + * The worker is forked into the managed provider's owning scope. Callers may + * therefore stop waiting without interrupting the shared probe for all other + * callers. The admission transition and worker fork are uninterruptible so an + * interrupt cannot leave an uncompleted Deferred installed in `inFlightRef`; + * the provider operation itself remains interruptible and is always converted + * to an Exit that completes every waiter. + */ +const makeSingleFlight = (scope: Scope.Scope): Effect.Effect> => + Effect.gen(function* () { + const inFlightRef = yield* Ref.make | null>(null); + + const run = (operation: Effect.Effect): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const candidate = yield* Deferred.make(); + const admission = yield* Ref.modify< + Deferred.Deferred | null, + SingleFlightAdmission + >( + inFlightRef, + (current): readonly [SingleFlightAdmission, Deferred.Deferred] => { + if (current !== null) { + return [{ deferred: current, leader: false }, current]; + } + return [{ deferred: candidate, leader: true }, candidate]; + }, + ); + + if (!admission.leader) { + return yield* restore(Deferred.await(admission.deferred)); + } + + yield* Effect.exit(Effect.interruptible(operation)).pipe( + Effect.flatMap((exit) => Deferred.done(candidate, exit)), + Effect.ensuring( + Ref.update(inFlightRef, (current) => (current === candidate ? null : current)), + ), + Effect.forkIn(scope), + ); + + return yield* restore(Deferred.await(candidate)); + }), + ); + + return { + current: Ref.get(inFlightRef), + run, + }; + }); + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { @@ -26,6 +92,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly refreshAccountUsage?: (input: { + readonly settings: Settings; + readonly snapshot: ServerProvider; + }) => Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -34,7 +104,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( }) => Effect.Effect; readonly refreshInterval?: Duration.Input | null; }): Effect.fn.Return { - const refreshSemaphore = yield* Semaphore.make(1); + // Full probes, settings changes, and usage-only updates all mutate the same + // snapshot. Keep those writes serialized even though duplicate calls of the + // same operation are coalesced independently below. + const snapshotMutationSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -48,6 +121,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const settingsRef = yield* Ref.make(initialSettings); const enrichmentFiberRef = yield* Ref.make | null>(null); const scope = yield* Effect.scope; + const fullRefreshSingleFlight = yield* makeSingleFlight( + scope, + ); + const accountUsageSingleFlight = yield* makeSingleFlight( + scope, + ); const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( generation: number, @@ -127,11 +206,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => - refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); + snapshotMutationSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { - const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* fullRefreshSingleFlight.run( + input.getSettings.pipe( + Effect.flatMap((nextSettings) => applySnapshot(nextSettings, { forceRefresh: true })), + ), + ); + }); + + const applyAccountUsageBase = Effect.fn("applyAccountUsage")(function* () { + if (!input.refreshAccountUsage) { + return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); + } + + const settings = yield* input.getSettings; + const currentState = yield* Ref.get(snapshotStateRef); + const accountRateLimits = yield* input.refreshAccountUsage({ + settings, + snapshot: currentState.snapshot, + }); + // A transient usage endpoint failure must not erase a known-good usage + // snapshot. Full provider health refreshes remain authoritative for + // clearing account-bound data after logout or account replacement. + if (accountRateLimits === undefined) { + return currentState.snapshot; + } + + const nextSnapshot: ServerProvider = { + ...currentState.snapshot, + accountRateLimits, + }; + if (Equal.equals(currentState.snapshot, nextSnapshot)) { + return currentState.snapshot; + } + + // Usage can land while asynchronous version enrichment is still working + // from an older base snapshot. Advance the generation and restart that + // enrichment so its eventual full-snapshot publish cannot overwrite the + // newer account usage. + const nextGeneration = input.enrichSnapshot + ? currentState.enrichmentGeneration + 1 + : currentState.enrichmentGeneration; + yield* Ref.set(snapshotStateRef, { + snapshot: nextSnapshot, + enrichmentGeneration: nextGeneration, + }); + yield* PubSub.publish(changesPubSub, nextSnapshot); + yield* restartSnapshotEnrichment(settings, nextSnapshot, nextGeneration); + return nextSnapshot; + }); + + const refreshAccountUsageSnapshot = Effect.fn("refreshAccountUsageSnapshot")(function* () { + // A full status refresh includes account usage. If one is already active, + // share its result instead of issuing a second authenticated HTTP request. + const activeFullRefresh = yield* fullRefreshSingleFlight.current; + if (activeFullRefresh !== null) { + return yield* Deferred.await(activeFullRefresh); + } + return yield* accountUsageSingleFlight.run( + snapshotMutationSemaphore.withPermits(1)(applyAccountUsageBase()), + ); }); yield* Stream.runForEach(input.streamSettings, (nextSettings) => @@ -147,10 +283,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ).pipe(Effect.forkScoped); } - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - ); + yield* refreshSnapshot().pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); return { maintenanceCapabilities: input.maintenanceCapabilities, @@ -160,6 +293,14 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Effect.orDie, ), refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + ...(input.refreshAccountUsage + ? { + refreshAccountUsage: refreshAccountUsageSnapshot().pipe( + Effect.tapError(Effect.logError), + Effect.orDie, + ), + } + : {}), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 40a00a00..69c8952b 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -167,11 +167,13 @@ describe("providerMaintenance", () => { expect( packageToolUpdate.resolve({ - binaryPath: "package-tool", + // The resolver receives the already-resolved executable in + // production. Pass that host-native path directly so this Darwin + // policy test does not ask a Windows runner to interpret a + // Windows PATH with POSIX delimiter semantics. + binaryPath: packageToolPath, platform: "darwin", - env: { - PATH: vitePlusBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("packageTool"), @@ -256,11 +258,9 @@ describe("providerMaintenance", () => { expect( scopedPackageToolUpdate.resolve({ - binaryPath: "scoped-package-tool", + binaryPath: scopedPackageToolPath, platform: "darwin", - env: { - PATH: pnpmHomeDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("scopedPackageTool"), @@ -315,11 +315,9 @@ describe("providerMaintenance", () => { expect( nativePackageToolUpdate.resolve({ - binaryPath: "native-package-tool", + binaryPath: nativePackageToolPath, platform: "darwin", - env: { - PATH: nativeBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("nativePackageTool"), @@ -350,11 +348,9 @@ describe("providerMaintenance", () => { expect( scopedPackageToolUpdate.resolve({ - binaryPath: "scoped-package-tool", + binaryPath: scopedPackageToolPath, platform: "darwin", - env: { - PATH: nativeBinDir, - }, + env: { PATH: "" }, }), ).toEqual({ provider: driver("scopedPackageTool"), diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 8e4077bc..072d3e5b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -168,6 +168,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshInstanceAccountUsage: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1c48233b..2e81572c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -44,6 +44,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -648,6 +649,7 @@ const buildAppUnderTest = (options?: { getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), + refreshInstanceAccountUsage: () => Effect.succeed([]), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), @@ -718,6 +720,7 @@ const buildAppUnderTest = (options?: { collectionEnabled: true, asOfMs: 0, days: [], + tokenBreakdown: [], }), snapshot: Effect.succeed({ totals: { generatingMs: 0, outputTokens: 0, userMessages: 0 }, @@ -3198,6 +3201,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; + const providerRefreshCalls = yield* Ref.make(0); const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -3233,6 +3238,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed(providers), + refresh: () => + Ref.update(providerRefreshCalls, (count) => count + 1).pipe(Effect.as(providers)), }, }, }); @@ -3251,7 +3258,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -3264,6 +3271,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { type: "keybindingsUpdated", payload: { keybindings: [], issues: [] }, }); + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(providerRefreshCalls), 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3459,13 +3468,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { + const path = yield* Path.Path; + const missingWorkspace = path.resolve("/definitely/not/a/real/workspace/path"); yield* buildAppUnderTest(); const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ - cwd: "/definitely/not/a/real/workspace/path", + cwd: missingWorkspace, query: "needle", limit: 10, }), @@ -3474,10 +3485,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assertInclude( - result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", - ); + assertInclude(result.failure.message, `Workspace root does not exist: ${missingWorkspace}`); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4086,6 +4094,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "refreshes Codex account usage without a full provider probe after prompt dispatch", + () => + Effect.gen(function* () { + const usageRefresh = yield* Deferred.make(); + const fullRefreshCalls = yield* Ref.make(0); + const provider = { + instanceId: defaultModelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "0.145.0", + status: "ready" as const, + auth: { status: "authenticated" as const, type: "chatgpt" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed([provider]), + refreshInstance: () => + Ref.update(fullRefreshCalls, (count) => count + 1).pipe(Effect.as([provider])), + refreshInstanceAccountUsage: (instanceId) => + Deferred.succeed(usageRefresh, instanceId).pipe( + Effect.ignore, + Effect.as([provider]), + ), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 8 }), + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-prompt-usage-refresh"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-prompt-usage-refresh"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }), + ), + ); + + assert.equal(result.sequence, 8); + assert.equal(yield* Deferred.await(usageRefresh), defaultModelSelection.instanceId); + assert.equal(yield* Ref.get(fullRefreshCalls), 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("filters thread subscription events already covered by the snapshot", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 58a24de2..6c45931c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type SourceControlProviderError } from "@cafecode/contracts"; @@ -106,10 +107,11 @@ it.effect("looks up repositories through the requested provider without search", it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = `${parent}/t3code`; + const destinationPath = path.join(parent, "t3code"); const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; yield* Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d53143e3..4d4b1b65 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -27,9 +27,42 @@ function makeFakeClaudeBinary(dir: string, output: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = path.join(dir, "bin"); - const claudePath = path.join(binDir, "claude"); yield* fs.makeDirectory(binDir, { recursive: true }); + if (process.platform === "win32") { + // Windows cannot execute a shebang-only extensionless fixture. Exercise + // the same `.cmd` shim path used by npm-installed Claude Code while a + // Node helper handles stdin/stdout without depending on PowerShell. + const fixturePath = path.join(binDir, "claude-fixture.cjs"); + const claudePath = path.join(binDir, "claude.cmd"); + yield* fs.writeFileString( + fixturePath, + [ + '"use strict";', + "let settled = false;", + "const deadline = setTimeout(() => {", + ' process.stderr.write("timed out waiting for prompt stdin\\n", () => process.exit(2));', + "}, 2000);", + 'process.stdin.setEncoding("utf8");', + 'process.stdin.on("data", (chunk) => {', + " if (settled || chunk.length === 0) return;", + " settled = true;", + " clearTimeout(deadline);", + " process.stdin.pause();", + ` process.stdout.write(${JSON.stringify(output)}, () => process.exit(0));`, + "});", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + claudePath, + `@echo off\r\n"${process.execPath}" "%~dp0claude-fixture.cjs" %*\r\n`, + ); + return claudePath; + } + + const claudePath = path.join(binDir, "claude"); + yield* fs.writeFileString( claudePath, [ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 117ba6a2..3c539bc9 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -48,9 +48,68 @@ function makeFakeCodexBinary( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = path.join(dir, "bin"); - const codexPath = path.join(binDir, "codex"); yield* fs.makeDirectory(binDir, { recursive: true }); + if (process.platform === "win32") { + // npm exposes Codex through a `.cmd` shim on Windows. Build the real + // process smoke fixture in that shape, with a Node helper so argument, + // stdin, stderr, and output-file behavior is identical across shells. + const fixturePath = path.join(binDir, "codex-fixture.cjs"); + const codexPath = path.join(binDir, "codex.cmd"); + const fixtureConfig = Buffer.from(JSON.stringify(input), "utf8").toString("base64"); + yield* fs.writeFileString( + fixturePath, + [ + '"use strict";', + 'const fs = require("node:fs");', + `const config = JSON.parse(Buffer.from(${JSON.stringify(fixtureConfig)}, "base64").toString("utf8"));`, + "const args = process.argv.slice(2);", + 'const outputFlag = args.indexOf("--output-last-message");', + "const outputPath = outputFlag >= 0 ? args[outputFlag + 1] : undefined;", + 'const configValues = args.flatMap((value, index) => args[index - 1] === "--config" ? [value] : []);', + 'const reasoningEffort = configValues.find((value) => value.startsWith("model_reasoning_effort="));', + 'let stdin = "";', + "let settled = false;", + "let idleTimer;", + 'const deadline = setTimeout(() => finish(8, "timed out waiting for prompt stdin"), 2000);', + 'process.stdin.setEncoding("utf8");', + "function finish(forcedCode, forcedMessage) {", + " if (settled) return;", + " settled = true;", + " clearTimeout(deadline);", + " if (idleTimer !== undefined) clearTimeout(idleTimer);", + " process.stdin.pause();", + " const fail = (code, message) => process.stderr.write(`${message}\\n`, () => process.exit(code));", + " if (forcedCode !== undefined) return fail(forcedCode, forcedMessage);", + ' if (config.requireImage && !args.includes("--image")) return fail(2, "missing --image input");', + ' if (config.requireFastServiceTier && !configValues.includes(\'service_tier="fast"\')) return fail(5, "missing fast service tier config");', + ' if (config.requireReasoningEffort !== undefined && reasoningEffort !== `model_reasoning_effort="${config.requireReasoningEffort}"`) return fail(6, `unexpected reasoning effort config: ${reasoningEffort ?? ""}`);', + " if (config.forbidReasoningEffort && reasoningEffort !== undefined) return fail(7, `reasoning effort config should be omitted: ${reasoningEffort}`);", + ' if (config.stdinMustContain !== undefined && !stdin.includes(config.stdinMustContain)) return fail(3, "stdin missing expected content");', + ' if (config.stdinMustNotContain !== undefined && stdin.includes(config.stdinMustNotContain)) return fail(4, "stdin contained forbidden content");', + " if (outputPath) fs.writeFileSync(outputPath, config.output);", + " const exitCode = config.exitCode ?? 0;", + " if (config.stderr !== undefined) return process.stderr.write(`${config.stderr}\\n`, () => process.exit(exitCode));", + " process.exit(exitCode);", + "}", + 'process.stdin.on("data", (chunk) => {', + " stdin += chunk;", + " if (idleTimer !== undefined) clearTimeout(idleTimer);", + " idleTimer = setTimeout(() => finish(), 50);", + "});", + 'process.stdin.on("end", () => finish());', + "", + ].join("\n"), + ); + yield* fs.writeFileString( + codexPath, + `@echo off\r\n"${process.execPath}" "%~dp0codex-fixture.cjs" %*\r\n`, + ); + return codexPath; + } + + const codexPath = path.join(binDir, "codex"); + yield* fs.writeFileString( codexPath, [ diff --git a/apps/server/src/usageStats/Layers/UsageStatsService.test.ts b/apps/server/src/usageStats/Layers/UsageStatsService.test.ts index 8485a00a..a89c27c0 100644 --- a/apps/server/src/usageStats/Layers/UsageStatsService.test.ts +++ b/apps/server/src/usageStats/Layers/UsageStatsService.test.ts @@ -354,7 +354,7 @@ describe("UsageStatsService", () => { yield* harness.emitProvider(tokenUsageEvent(THREAD_1, "p5", { outputTokens: 150 }, CODEX)); yield* harness.service.flush; - assert.deepEqual(yield* harness.repository.listTokenBreakdownDays, [ + const expectedRows = [ { day: "1970-01-01", provider: CLAUDE, @@ -373,7 +373,16 @@ describe("UsageStatsService", () => { model: "gpt-5.6-codex-mini", outputTokens: 50, }, - ]); + ]; + assert.deepEqual(yield* harness.repository.listTokenBreakdownDays, expectedRows); + + const expectedLifetimeBreakdown = expectedRows.map(({ day: _day, ...row }) => row); + assert.deepEqual((yield* harness.service.get).tokenBreakdown, expectedLifetimeBreakdown); + + // A fresh process must reconstruct the same lifetime view from the + // daily ledger without a Settings-page SQL query. + const rebuiltService = yield* harness.rebuildService; + assert.deepEqual((yield* rebuiltService.get).tokenBreakdown, expectedLifetimeBreakdown); }), ), ); diff --git a/apps/server/src/usageStats/Layers/UsageStatsService.ts b/apps/server/src/usageStats/Layers/UsageStatsService.ts index cbe32250..839e3873 100644 --- a/apps/server/src/usageStats/Layers/UsageStatsService.ts +++ b/apps/server/src/usageStats/Layers/UsageStatsService.ts @@ -1,9 +1,10 @@ /** * UsageStatsServiceLive - In-memory usage accumulator with periodic SQLite flush. * - * Building the layer hydrates lifetime counters from `usage_stats_days`, forks - * consumers of the domain-event and provider-runtime streams, and forks a - * flush loop that accrues in-flight generating time and persists pending + * Building the layer hydrates lifetime counters from `usage_stats_days` and + * provider/model token attribution from `usage_stats_token_breakdown_days`, + * forks consumers of the domain-event and provider-runtime streams, and forks + * a flush loop that accrues in-flight generating time and persists pending * per-day deltas every few seconds. A finalizer performs one last * accrue-and-flush on shutdown, so a clean stop loses nothing and a hard kill * loses at most one flush interval. @@ -22,10 +23,12 @@ * and accrual cursors always advance, so toggling collection partitions time * and tokens cleanly instead of retroactively counting the disabled period. */ -import type { - OrchestrationEvent, - ProviderDriverKind, - ProviderRuntimeEvent, +import { + USAGE_STATS_MODEL_MAX_CHARS, + type OrchestrationEvent, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type UsageStatsTokenBreakdownEntry, } from "@cafecode/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -43,7 +46,6 @@ import { UsageStatsService, type UsageStatsServiceShape } from "../Services/Usag const FLUSH_INTERVAL_MS = 5_000; const MODEL_RESOLUTION_TIMEOUT_MS = 1_000; -const MAX_USAGE_MODEL_CHARS = 256; const UNKNOWN_USAGE_MODEL = "unknown"; interface MutableDayTotals { @@ -76,6 +78,7 @@ interface ThreadTracking { } type PendingTokenBreakdowns = Map>>; +type TokenBreakdownTotals = Map>; /** * Best-effort output-token extraction from an opaque `turn.completed` usage @@ -96,7 +99,7 @@ function normalizeUsageModel(value: unknown): string | undefined { return undefined; } const normalized = value.trim(); - return normalized.length > 0 && normalized.length <= MAX_USAGE_MODEL_CHARS + return normalized.length > 0 && normalized.length <= USAGE_STATS_MODEL_MAX_CHARS ? normalized : undefined; } @@ -131,10 +134,60 @@ const makeUsageStatsService = Effect.gen(function* () { const days = new Map(); const pending = new Map(); const pendingTokenBreakdowns: PendingTokenBreakdowns = new Map(); + const tokenBreakdownTotals: TokenBreakdownTotals = new Map(); const threads = new Map(); const totals: MutableDayTotals = { generatingMs: 0, outputTokens: 0, userMessages: 0 }; + let tokenBreakdownSnapshot: ReadonlyArray = []; + let tokenBreakdownSnapshotDirty = true; let enabled = true; + const addTokenBreakdownTotal = ( + provider: ProviderDriverKind, + model: string, + outputTokens: number, + ): void => { + if (outputTokens <= 0) { + return; + } + let models = tokenBreakdownTotals.get(provider); + if (models === undefined) { + models = new Map(); + tokenBreakdownTotals.set(provider, models); + } + models.set(model, (models.get(model) ?? 0) + outputTokens); + tokenBreakdownSnapshotDirty = true; + }; + + /** + * Materialize the RPC rows only after attribution changes. Usage snapshots + * are read frequently, while model attribution changes only when a provider + * reports additional output tokens. + */ + const readTokenBreakdown = (): ReadonlyArray => { + if (!tokenBreakdownSnapshotDirty) { + return tokenBreakdownSnapshot; + } + tokenBreakdownSnapshot = Array.from(tokenBreakdownTotals.entries()) + .flatMap(([provider, models]) => + Array.from(models.entries(), ([model, outputTokens]) => ({ + provider, + model, + outputTokens, + })), + ) + .toSorted((left, right) => { + if (left.provider !== right.provider) { + return left.provider < right.provider ? -1 : 1; + } + if (left.outputTokens !== right.outputTokens) { + return right.outputTokens - left.outputTokens; + } + return left.model < right.model ? -1 : left.model > right.model ? 1 : 0; + }); + tokenBreakdownSnapshotDirty = false; + return tokenBreakdownSnapshot; + }; + yield* repository.listDays.pipe( Effect.map((rows) => { for (const row of rows) { @@ -155,6 +208,19 @@ const makeUsageStatsService = Effect.gen(function* () { ), ); + yield* repository.listTokenBreakdownDays.pipe( + Effect.map((rows) => { + for (const row of rows) { + addTokenBreakdownTotal(row.provider, row.model, row.outputTokens); + } + }), + // Aggregate usage remains useful if only the attribution ledger is + // damaged. Keep this failure isolated and let current-session rows accrue. + Effect.catch((error) => + Effect.logError("usage stats: failed to hydrate token breakdown", { error }), + ), + ); + enabled = yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.usageStatsEnabled), Effect.catch(() => Effect.succeed(true)), @@ -197,6 +263,9 @@ const makeUsageStatsService = Effect.gen(function* () { } addDelta(day, { outputTokens }); + const modelKey = model ?? UNKNOWN_USAGE_MODEL; + addTokenBreakdownTotal(provider, modelKey, outputTokens); + let providers = pendingTokenBreakdowns.get(day); if (providers === undefined) { providers = new Map(); @@ -207,7 +276,6 @@ const makeUsageStatsService = Effect.gen(function* () { models = new Map(); providers.set(provider, models); } - const modelKey = model ?? UNKNOWN_USAGE_MODEL; models.set(modelKey, (models.get(modelKey) ?? 0) + outputTokens); }; @@ -555,7 +623,7 @@ const makeUsageStatsService = Effect.gen(function* () { (left, right) => (left.day < right.day ? -1 : 1), ) : dayRows; - return { ...state, days: withLiveToday }; + return { ...state, days: withLiveToday, tokenBreakdown: readTokenBreakdown() }; }); yield* Effect.forkScoped( diff --git a/apps/server/src/usageStats/Services/UsageStatsService.ts b/apps/server/src/usageStats/Services/UsageStatsService.ts index 87aced8f..8707ddd5 100644 --- a/apps/server/src/usageStats/Services/UsageStatsService.ts +++ b/apps/server/src/usageStats/Services/UsageStatsService.ts @@ -13,8 +13,8 @@ import type * as Effect from "effect/Effect"; export interface UsageStatsServiceShape { /** - * Lifetime totals plus the full per-day history for the activity heatmap. - * Served from memory. + * Lifetime totals, full per-day history for the activity heatmap, and + * provider/model output-token attribution. Served entirely from memory. */ readonly get: Effect.Effect; diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index 6290cbb6..fcd02bb6 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -11,10 +11,6 @@ import * as Option from "effect/Option"; import type { VcsDriverKind } from "@cafecode/contracts"; import * as VcsDriver from "../VcsDriver.ts"; -function normalizePathForComparison(value: string): string { - return value.replaceAll("\\", "/"); -} - export interface VcsDriverFixture { readonly createRepo: (cwd: string) => Effect.Effect; readonly writeFile: ( @@ -65,17 +61,19 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp it.effect("detects repository identity inside a repository and nested directories", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); + const fileSystem = yield* FileSystem.FileSystem; const driver = yield* VcsDriver.VcsDriver; yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); const identity = yield* driver.detectRepository(cwd); assert.equal(identity?.kind, input.kind); - assert.isTrue( - normalizePathForComparison(identity?.rootPath ?? "").endsWith( - normalizePathForComparison(cwd), - ), - ); + const [identityInfo, cwdInfo] = yield* Effect.all([ + fileSystem.stat(identity?.rootPath ?? ""), + fileSystem.stat(cwd), + ]); + assert.equal(identityInfo.dev, cwdInfo.dev); + assert.deepStrictEqual(identityInfo.ino, cwdInfo.ino); assert.equal(identity?.freshness.source, "live-local"); assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b2067177..8ce840d7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -565,8 +565,11 @@ const makeWsRpcLayer = ( const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const shouldRefresh = yield* Ref.modify(codexPromptUsageRefreshAtRef, (previous) => { - const previousRefreshAt = previous.get(instanceId) ?? 0; - if (nowMs - previousRefreshAt < CODEX_PROMPT_USAGE_REFRESH_THROTTLE_MS) { + const previousRefreshAt = previous.get(instanceId); + if ( + previousRefreshAt !== undefined && + nowMs - previousRefreshAt < CODEX_PROMPT_USAGE_REFRESH_THROTTLE_MS + ) { return [false, previous] as const; } const next = new Map(previous); @@ -580,7 +583,7 @@ const makeWsRpcLayer = ( // Provider account usage is display metadata, never part of the // prompt-send critical path. Refresh it in the background and let the // normal provider snapshot stream update the renderer when it lands. - yield* providerRegistry.refreshInstance(instanceId).pipe( + yield* providerRegistry.refreshInstanceAccountUsage(instanceId).pipe( Effect.catchCause((cause) => Effect.logWarning("codex prompt usage refresh failed", { commandType: command.type, @@ -1318,10 +1321,12 @@ const makeWsRpcLayer = ( })), ); - yield* providerRegistry - .refresh() - .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - + // A renderer subscription is a read boundary, not a provider + // maintenance command. Managed snapshots already perform an + // initial probe and periodic refresh; forcing every provider + // here made reconnect storms repeatedly execute CLI version and + // login checks. The initial event below reads the latest cached + // snapshots, then `providerStatuses` carries future changes. const liveUpdates = Stream.merge( keybindingsUpdates, Stream.merge( diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 7e8213ec..c904a2b9 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -2,6 +2,8 @@ import { configDefaults, defineConfig, mergeConfig } from "vitest/config"; import baseConfig from "../../vitest.config.ts"; +const SERVER_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; + export default mergeConfig( baseConfig, defineConfig({ @@ -18,8 +20,8 @@ export default mergeConfig( isolate: true, // Server integration tests exercise sqlite, git, and orchestration together. // Under package-wide parallel runs they regularly exceed the default 15s budget. - testTimeout: 60_000, - hookTimeout: 60_000, + testTimeout: SERVER_TEST_TIMEOUT_MS, + hookTimeout: SERVER_TEST_TIMEOUT_MS, }, }), ); diff --git a/apps/web/package.json b/apps/web/package.json index e2649064..a03b086a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/web", - "version": "0.0.51", + "version": "0.1.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/web/src/components/ChatViewBrowser.shared.tsx b/apps/web/src/components/ChatViewBrowser.shared.tsx index 28fcf8fc..ca591a3a 100644 --- a/apps/web/src/components/ChatViewBrowser.shared.tsx +++ b/apps/web/src/components/ChatViewBrowser.shared.tsx @@ -12,6 +12,7 @@ import { type ProjectId, ProviderDriverKind, ProviderInstanceId, + type RuntimeMode, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -228,7 +229,13 @@ function createSnapshotForTargetUser(options: { targetText: string; targetAttachmentCount?: number; sessionStatus?: OrchestrationSessionStatus; + provider?: "codex" | "claudeAgent"; + runtimeMode?: RuntimeMode; }): OrchestrationReadModel { + const provider = ProviderDriverKind.make(options.provider ?? "codex"); + const instanceId = ProviderInstanceId.make(options.provider ?? "codex"); + const model = provider === "claudeAgent" ? "claude-opus-4-8" : "gpt-5"; + const runtimeMode = options.runtimeMode ?? "full-access"; const messages: Array = []; for (let index = 0; index < 22; index += 1) { @@ -273,8 +280,8 @@ function createSnapshotForTargetUser(options: { workspaceRoot: "/repo/project", additionalWorkspaceRoots: [], defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", + instanceId, + model, }, scripts: [], createdAt: NOW_ISO, @@ -288,11 +295,11 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: THREAD_TITLE, modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", + instanceId, + model, }, interactionMode: "default", - runtimeMode: "full-access", + runtimeMode, branch: "main", worktreePath: null, latestTurn: null, @@ -307,8 +314,8 @@ function createSnapshotForTargetUser(options: { session: { threadId: THREAD_ID, status: options.sessionStatus ?? "ready", - providerName: "codex", - runtimeMode: "full-access", + providerName: provider, + runtimeMode, activeTurnId: null, lastError: null, updatedAt: NOW_ISO, @@ -1378,7 +1385,7 @@ async function expectComposerActionsContained(): Promise { } async function waitForInteractionModeButton( - expectedLabel: "Build" | "Plan", + expectedLabel: "Build" | "Plan" | "Manual" | "Accept edits" | "Auto", ): Promise { return waitForElement( () => @@ -3163,6 +3170,66 @@ describe(`ChatView full app (${chatViewBrowserPart})`, () => { } }); + it("cycles Claude's four normal permission modes with Shift+Tab", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-target-claude-mode-hotkey" as MessageId, + targetText: "claude mode hotkey target", + provider: "claudeAgent", + runtimeMode: "approval-required", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.216", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + }; + }, + }); + + try { + await waitForInteractionModeButton("Manual"); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + for (const expectedLabel of ["Accept edits", "Plan", "Auto", "Manual"] as const) { + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + await waitForInteractionModeButton(expectedLabel); + } + } finally { + await mounted.cleanup(); + } + }); + it("uses the active draft route session when changing the base branch", async () => { const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0e68baad..a49ac677 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -114,10 +114,12 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, + type DesktopUpdateButtonAction, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; @@ -3233,7 +3235,7 @@ interface SidebarProjectsContentProps { bootstrappedEnvironmentIds: ReadonlySet; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonAction: DesktopUpdateButtonAction; desktopUpdateButtonDisabled: boolean; handleDesktopUpdateButtonClick: () => void; desktopDebugEnabled: boolean; @@ -3395,7 +3397,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > {desktopUpdateButtonAction === "download" ? "Download ARM build" - : "Install ARM build"} + : desktopUpdateButtonAction === "manual" + ? "View ARM build" + : "Install ARM build"} ) : null} @@ -4112,6 +4116,27 @@ export default function Sidebar() { if (!bridge || !desktopUpdateState) return; if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; + if (desktopUpdateButtonAction === "manual") { + void bridge + .openExternal(getDesktopUpdateReleaseUrl(desktopUpdateState.availableVersion)) + .then((opened) => { + if (opened) return; + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open release", + description: "Open the Cafe Code releases page in your browser to install the DMG.", + }); + }); + return; + } + if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3c0b0d7e..836bab87 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -93,6 +93,7 @@ import { LockIcon, LockOpenIcon, PenLineIcon, + ShieldCheckIcon, Trash2Icon, XIcon, } from "lucide-react"; @@ -114,6 +115,15 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useHasOnScreenKeyboard } from "../../hooks/useMediaQuery"; import { domSnapshot, mobileDebugLog } from "../../lib/mobileDebugLog"; +import { + applyClaudePermissionMode, + CLAUDE_PERMISSION_MODE_OPTIONS, + type ClaudePermissionMode, + deriveClaudePermissionMode, + getClaudePermissionModeOption, + getNextClaudePermissionMode, + isClaudePermissionMode, +} from "./claudePermissionMode"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -139,6 +149,13 @@ const runtimeModeConfig: Record< }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const claudePermissionModeIcons: Record = { + default: LockIcon, + acceptEdits: PenLineIcon, + plan: BotIcon, + auto: ShieldCheckIcon, + bypassPermissions: LockOpenIcon, +}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_FLOATING_LAYER_SELECTOR = [ @@ -165,6 +182,7 @@ function isInsideComposerFloatingLayer(element: Element): boolean { } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + provider: ProviderDriverKind; showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; @@ -172,17 +190,67 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop planSidebarLabel: string; planSidebarOpen: boolean; onToggleInteractionMode: () => void; + onClaudePermissionModeChange: (mode: ClaudePermissionMode) => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { + const isClaude = props.provider === "claudeAgent"; const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; + const claudePermissionMode = deriveClaudePermissionMode({ + interactionMode: props.interactionMode, + runtimeMode: props.runtimeMode, + }); + const claudePermissionModeOption = getClaudePermissionModeOption(claudePermissionMode); + const ClaudePermissionModeIcon = claudePermissionModeIcons[claudePermissionMode]; return ( <> - {props.showInteractionModeToggle ? ( + {props.showInteractionModeToggle && isClaude ? ( + <> + + + + + ) : props.showInteractionModeToggle ? ( <>