diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f778d8b5..4c983423 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -182,6 +182,25 @@ jobs: name: muse-spark-code-vsix path: '*.vsix' if-no-files-found: error + # The ACP agent's npm package (M63, PLAN.md D62), from the same + # production build `npm run package` just ran. + - run: node scripts/package-acp.mjs + - name: the agent's package carries its bundles, tables, notices and manifest + run: | + listing="$(tar -tzf dist/muse-spark-code-acp-*.tgz)" + for entry in package/package.json package/dist/acp.js package/dist/searchWorker.js \ + package/THIRD_PARTY_NOTICES.txt package/LICENSE package/README.md package/l10n/ui.de.json; do + if ! grep -qx "$entry" <<< "$listing"; then + echo "::error::$entry is missing from the agent's package" >&2 + exit 1 + fi + done + echo "the agent's package holds $(wc -l <<< "$listing") entries, the required ones included" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: muse-spark-code-acp + path: dist/muse-spark-code-acp-*.tgz + if-no-files-found: error secrets: name: gitleaks diff --git a/.github/workflows/forks.yml b/.github/workflows/forks.yml new file mode 100644 index 00000000..bb5b2c30 --- /dev/null +++ b/.github/workflows/forks.yml @@ -0,0 +1,79 @@ +# The VS Code forks that ship Linux builds (PLAN.md M62b): Cursor, Devin +# Desktop (Windsurf until 2026), Kiro and Positron, each at its latest +# release from its own update feed (test/hosts/fork-release.mjs). Each job +# installs the .vsix with the fork's CLI and runs the integration tests in +# the fork; the job summary names the fork's version and its VS Code base. +# +# Apart from hosts.yml because these are the forks' latest builds, not +# pinned ones, and their feeds can change without notice: every Monday, by +# hand, and on pull requests that change this check. Every job has a +# timeout and leaves no token in the git config; none pushes. +name: Forks + +on: + pull_request: + paths: + - 'test/hosts/fork-release.mjs' + - 'test/hosts/installed.vscode-test.mjs' + - 'test/hosts/run-fork.sh' + - '.github/workflows/forks.yml' + schedule: + - cron: '41 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: forks-${{ github.ref }} + cancel-in-progress: true + +jobs: + vsix: + name: vsix + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run package + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: forks-vsix + path: '*.vsix' + if-no-files-found: error + + fork: + name: ${{ matrix.fork }} + needs: vsix + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + fork: [cursor, devin-desktop, kiro, positron] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build:dev + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: forks-vsix + path: forks-vsix + - name: ${{ matrix.fork }} + env: + # Positron's latest release is read from the GitHub API. + GH_TOKEN: ${{ github.token }} + run: xvfb-run -a sh test/hosts/run-fork.sh "${{ matrix.fork }}" forks-vsix/*.vsix "$RUNNER_TEMP/fork" diff --git a/.github/workflows/hosts.yml b/.github/workflows/hosts.yml new file mode 100644 index 00000000..cbe93ca5 --- /dev/null +++ b/.github/workflows/hosts.yml @@ -0,0 +1,267 @@ +# The host checks (PLAN.md M62, M63): the extension in the editors built on +# VS Code, and the ACP agent in the editors that speak ACP, each driven as +# docs/certification/m62.md and m63.md recorded it, against the fake Muse +# Code CLI (no model is called and no real key is used). Each job runs one +# script of test/hosts, the same script a local run uses. +# +# Pull requests and pushes to main that touch the product or these checks, +# every Monday (the hosts' own new releases), and by hand. Every job has a +# timeout and leaves no token in the git config; none pushes. +name: Hosts + +on: + pull_request: + paths: + - 'src/**' + - 'l10n/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/build.mjs' + - 'scripts/package-acp.mjs' + - 'test/e2e/**' + - 'test/hosts/**' + - 'test/integration/**' + - '.github/workflows/hosts.yml' + push: + branches: [main] + paths: + - 'src/**' + - 'l10n/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/build.mjs' + - 'scripts/package-acp.mjs' + - 'test/e2e/**' + - 'test/hosts/**' + - 'test/integration/**' + - '.github/workflows/hosts.yml' + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hosts-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The .vsix (without the macOS dictation helper, which no check here + # needs) and the agent's npm package, from one production build. + packages: + name: packages + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run package + - run: node scripts/package-acp.mjs + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hosts-vsix + path: '*.vsix' + if-no-files-found: error + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hosts-acp + path: dist/muse-spark-code-acp-*.tgz + if-no-files-found: error + + # The agent as npm installs it, on each platform: the stdio suite against + # the installed package (its own native keyring binding, no repository + # modules), then the key's round trip through the platform's credential + # store (D61). + agent: + name: agent package (${{ matrix.os }}) + needs: packages + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: hosts-acp + path: hosts-acp + - run: npm install --global ./hosts-acp/muse-spark-code-acp-*.tgz + - name: the stdio suite against the installed package + run: MUSE_ACP_PACKAGE_DIR="$(npm root --global)/muse-spark-code-acp" npx vitest run test/e2e/acpStdio.e2e.test.ts + - name: the key through the Secret Service (linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -y -q gnome-keyring + dbus-run-session -- sh -c 'printf "ci\n" | gnome-keyring-daemon --unlock --components=secrets > /dev/null && sh test/hosts/keystore.sh muse-spark-code-acp' + # A keychain of the job's own, unlocked, as the default: the runner's + # login keychain would ask a person to unlock it. + - name: the key through the Keychain (macos) + if: runner.os == 'macOS' + run: | + security create-keychain -p ci hosts.keychain + security list-keychains -d user -s hosts.keychain login.keychain + security default-keychain -d user -s hosts.keychain + security unlock-keychain -p ci hosts.keychain + security set-keychain-settings hosts.keychain + sh test/hosts/keystore.sh muse-spark-code-acp + - name: the key through Credential Manager (windows) + if: runner.os == 'Windows' + run: sh test/hosts/keystore.sh muse-spark-code-acp + + # The integration tests in VSCodium: the floor's release and the latest. + vscodium: + name: VSCodium ${{ matrix.release }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + release: ['1.99.32846', latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build:dev + - run: xvfb-run -a sh test/hosts/run-vscodium.sh "${{ matrix.release }}" "$RUNNER_TEMP/vscodium" + + # The .vsix in code-server, the floor's release (VS Code 1.99.3, Node + # 20.18) and the latest, driven in Chrome. + code-server: + name: code-server ${{ matrix.release }} + needs: packages + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + release: ['4.99.4', latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: hosts-vsix + path: hosts-vsix + - name: code-server + env: + GH_TOKEN: ${{ github.token }} + RELEASE: ${{ matrix.release }} + run: | + version="$RELEASE" + if [ "$version" = latest ]; then + version="$(gh release view --repo coder/code-server --json tagName --jq .tagName | sed 's/^v//')" + fi + mkdir -p "$RUNNER_TEMP/code-server" + curl -fsSL "https://github.com/coder/code-server/releases/download/v$version/code-server-$version-linux-amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP/code-server" --strip-components=1 + - run: sh test/hosts/run-code-server.sh "$RUNNER_TEMP/code-server/bin/code-server" hosts-vsix/*.vsix "$RUNNER_TEMP/code-server-check" + - if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: code-server-${{ matrix.release }}-evidence + path: | + ${{ runner.temp }}/code-server-check/shots + ${{ runner.temp }}/code-server-check/code-server.log + ${{ runner.temp }}/code-server-check/data/logs + + # The .vsix in Eclipse Theia (test/hosts/theia/package.json), built from npm. + theia: + name: Eclipse Theia + needs: packages + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: hosts-vsix + path: hosts-vsix + - run: sh test/hosts/run-theia.sh hosts-vsix/*.vsix "$RUNNER_TEMP/theia-check" + - if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: theia-evidence + path: | + ${{ runner.temp }}/theia-check/shots + ${{ runner.temp }}/theia-check/theia.log + + # The agent in the ACP clients that install on Linux: JupyterLab (Jupyter + # AI), Emacs (acp.el, agent-shell) and Neovim (CodeCompanion). + acp-clients: + name: ACP client ${{ matrix.client }} + needs: packages + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + client: [jupyter, emacs, neovim] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + if: matrix.client == 'jupyter' + with: + python-version: '3.12' + - run: npm ci + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: hosts-acp + path: hosts-acp + - run: npm install --global ./hosts-acp/muse-spark-code-acp-*.tgz + - if: matrix.client == 'emacs' + run: | + sudo apt-get update -q + sudo apt-get install -y -q emacs-nox + - run: sh "test/hosts/run-${{ matrix.client }}.sh" "$(command -v muse-spark-code-acp)" "$RUNNER_TEMP/${{ matrix.client }}-check" + - if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.client }}-evidence + path: | + ${{ runner.temp }}/${{ matrix.client }}-check/shots + ${{ runner.temp }}/${{ matrix.client }}-check/*.log + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86ecfa1d..7a8110de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,12 @@ # and the Marketplace publish runs when the VSCE_PAT secret of the # `marketplace` environment is set (a Marketplace "Manage" PAT scoped to the # publisher's organisation; without it the publish step is skipped and -# reported, and `npx vsce publish --packagePath` by hand still works). +# reported, and `npx vsce publish --packagePath` by hand still works). The +# same .vsix goes to Open VSX, for VSCodium, Cursor, Kiro, Positron and the +# other editors that install from it, when the environment's OVSX_PAT is +# set (PLAN.md D62, M62), under the same rules. The ACP agent's package +# (muse-spark-code-acp, M63) rides on the GitHub Release and goes to npm +# when the environment's NPM_TOKEN is set (Q65). # # Before anything is built, the tag must name the manifest's version and # point at a commit on main. The PAT reaches one step only, after an @@ -69,13 +74,17 @@ jobs: with: name: muse-spark-code-vsix path: release + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: muse-spark-code-acp + path: release - name: release notes from the CHANGELOG run: node scripts/changelog-notes.mjs "${GITHUB_REF_NAME#v}" > release/notes.md - name: create the GitHub Release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" release/*.vsix \ + gh release create "${GITHUB_REF_NAME}" release/*.vsix release/*.tgz \ --title "${GITHUB_REF_NAME}" \ --notes-file release/notes.md \ --verify-tag @@ -122,3 +131,82 @@ jobs: env: VSCE_PAT: ${{ secrets.VSCE_PAT }} run: ./node_modules/.bin/vsce publish --packagePath release/*.vsix + + openvsx: + name: Open VSX publish + needs: release + runs-on: ubuntu-latest + timeout-minutes: 10 + # Its own job, so either registry failing leaves the other published; + # the token lives in the same tag-only environment as VSCE_PAT. + environment: marketplace + env: + HAS_OVSX_PAT: ${{ secrets.OVSX_PAT != '' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: muse-spark-code-vsix + path: release + - name: skipped without OVSX_PAT + if: env.HAS_OVSX_PAT != 'true' + run: echo "OVSX_PAT is not set; the Open VSX publish was skipped. Publish by hand with npx ovsx publish release/*.vsix" >> "$GITHUB_STEP_SUMMARY" + # The locked ovsx, with no install script of any package run. + - name: install the locked tools without install scripts + if: env.HAS_OVSX_PAT == 'true' + run: npm ci --ignore-scripts --no-audit + # Open VSX refuses a publish into a namespace that does not exist, and + # the package's publisher had none when this was checked (2026-09-27, + # `GET /api/RandyNorthrup` 404). The first run creates it with the same + # token; later runs find it (200) and skip. Any other answer stops here. + - name: create the Open VSX namespace if it is missing + if: env.HAS_OVSX_PAT == 'true' + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + namespace=$(node -p "require('./package.json').publisher") + status=$(curl -s -o /dev/null -w '%{http_code}' "https://open-vsx.org/api/${namespace}") + if [ "$status" = "404" ]; then + ./node_modules/.bin/ovsx create-namespace "$namespace" + elif [ "$status" != "200" ]; then + echo "Open VSX answered HTTP $status for namespace $namespace" >&2 + exit 1 + fi + - name: publish to Open VSX + if: env.HAS_OVSX_PAT == 'true' + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: ./node_modules/.bin/ovsx publish release/*.vsix + + npm: + name: npm publish (ACP agent) + needs: release + runs-on: ubuntu-latest + timeout-minutes: 10 + # The same tag-only environment as the other registries' tokens. + environment: marketplace + env: + HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }} + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: muse-spark-code-acp + path: release + - name: skipped without NPM_TOKEN + if: env.HAS_NPM_TOKEN != 'true' + run: echo "NPM_TOKEN is not set; muse-spark-code-acp was not published to npm. The GitHub Release carries its package." >> "$GITHUB_STEP_SUMMARY" + # The packed tarball as it is: no install, no script of any package. + - name: publish muse-spark-code-acp to npm + if: env.HAS_NPM_TOKEN == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish release/muse-spark-code-acp-*.tgz --access public --ignore-scripts diff --git a/AGENTS.md b/AGENTS.md index 8cc237e0..c8d71286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,24 @@ them, the milestone plan, and the certification checklist. 8. **Secrets never leave SecretStorage.** No API keys in settings, logs, telemetry, tests, or fixtures. The pasted Model API key is never passed to any child process: the Muse Code CLI signs in on its own. + - **Outside VS Code (the ACP agent, PLAN.md D61).** The operating + system's credential store stands in for SecretStorage: it is the store + VS Code's SecretStorage itself rests on. The key goes in only through + `muse-spark-code-acp auth set`, from its standard input (the user's + terminal, or a pipe into it); never from an environment variable, an + argument or a file; and it is never passed to a child process + (`muse serve`, a tool, a check). A `META_API_KEY` the user sets in the + agent's own environment is theirs for Muse Code, not the stored key: + as the extension's `muse serve` inherits it (PLAN.md D1), it reaches + Muse Code's processes only, where it counts as Muse Code's credential. + The agent takes every credential variable (`*_API_KEY` and the named + ones hooks never get) out of its own environment at start, so no + shell command, hook, git or helper it starts sees one. + - **The one exception: M80's CI bootstrap** (PLAN.md M80, planned). + GitHub hands a secret to a step only through its environment or its + script, so the Action's own step shell is the one environment the key + is ever in: that shell pipes it to `auth set`'s standard input and + unsets it before `exec` starts. Nothing else is excepted. - **The CLI's credential file.** The extension reads only its structure (`src/core/backends/musecode/credentialFile.ts`): the schema version, which providers are named (only `meta` speaks for the sign-in), each @@ -92,7 +110,9 @@ them, the milestone plan, and the certification checklist. `muse serve`). Every use asks first in the paid-use popup (M58, D48: `PaidUseConsent` in `src/core/paid/paidConsent.ts`, Allow once / Allow always in this workspace / Deny), in every mode, Bypass included; a - paid call never gets an approval card or a session rule. The + paid call never gets an approval card or a session rule. In the ACP + agent (D62) a feature is on only with its flag, and the same question + is the editor's permission prompt (`src/acp/paid.ts`). The subscription never pays for one. One exception to the key is planned (PLAN.md D50, M85, experimental): the TypeSafe assist is billed to the user's own TypeSafe key instead of the Model API key; every other part @@ -118,6 +138,11 @@ src/core/** backend-agnostic logic; must not import `vscode` (MSP host, Model API client and tools, the MCP client, context, Muse Code's memory, export, worktrees, usage, dictation, Muse Voice, the paid gate, network failures) +src/acp/** the ACP agent (D62): the ACP side of a session and the + translation of the engine's events; must not import + `vscode` +src/runtime/** the agent's process: arguments, backends outside VS Code, + the OS credential store (D61), `auth` and `login` src/shared/** constants + zod protocol shared by host and webview src/shared/l10n/** the English table (en.ts), fill/plural/Intl helpers, the table checks and the list of translated languages @@ -140,12 +165,20 @@ test/e2e/** the fake Muse Code CLI driven through the real backend; test/integration/** @vscode/test-cli, runs inside VS Code test/harness/ the webview behind a fake host, for screenshots and the accessibility gate; themes/ holds VS Code's four themes +test/hosts/ the extension and the ACP agent in other editors + against the fake CLI, one script per host (hosts.yml) scripts/** esbuild build; bundle-size, bundle-split, host-globals, - notices, audit, PSScriptAnalyzer, semgrep, accessibility - and localization gates; theme capture, the pseudo-locale, - harness screenshots, image rendering, changelog notes, - VS Code versions for CI + notices, audit, PSScriptAnalyzer, semgrep, accessibility, + localization and host API gates; theme capture, the + pseudo-locale, harness screenshots, image rendering, + changelog notes, VS Code versions for CI, the ACP + agent's package docs/certification/ per-milestone gate-fire records and screenshots +docs/ide-compatibility.md, docs/ide-compatibility/ + the plan for editors beyond VS Code (D60), the + generated record of what the extension asks of its host, + and hosts.md, what each editor was tested at +docs/acp.md the ACP agent's guide, shipped as its package's README media/ icons, banner, social preview, README screenshots ``` @@ -157,12 +190,15 @@ media/ icons, banner, social preview, README screenshots | The gates CI runs everywhere | `npm run quality:gates` | | Accessibility gate | `npm run test:a11y` | | Localization gate | `npm run check:l10n` | +| Host API record (D60) | `npm run check:host-api` (`-- --write`) | | Panel in the pseudo-locale | `npm run harness:shots -- --lang=pseudo` | | Unit tests with coverage | `npm run test:unit` | | Integration tests | `npm run test:integration` | | Dev build / watch | `npm run build:dev` / `npm run watch` | | Production build + size budget | `npm run build` | | Package `.vsix` | `npm run package` | +| Package the ACP agent (D62) | `npm run package:acp` | +| Host checks (hosts.yml) | `sh test/hosts/run-.sh` | ## Toolchain pins that matter diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eed1bb8..b5b8e292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,117 @@ happened, not what was planned; superseded entries are kept. ## [Unreleased] +### Added + +- **Groundwork for editors other than VS Code** (M60, M61, PLAN.md D60). + The owner's IDE compatibility plan is filed in `docs/ide-compatibility.md`. + A new gate, `npm run check:host-api`, keeps a record of what the + extension asks of its host (`docs/ide-compatibility/host-api.md`): the + 201 VS Code APIs it uses and where, the 13 files that import `vscode`, + the Node built-ins, and what the webview needs (`acquireVsCodeApi` and 57 + theme variables); it fails when the record goes stale, and when the + engine, the protocol, the webview, the conversation controller, either + backend or the credential store reaches `vscode`. The webview now talks + to VS Code through one host bridge, and the chat surface, the log and the + dictation setup carry no VS Code types. Nothing changes in VS Code. +- **Muse Spark for editors that speak ACP** (M63, PLAN.md D61, D62). + `muse-spark-code-acp`, an npm package attached to each GitHub Release, + runs Muse Code or the Model API as an Agent Client Protocol agent for + Zed, JetBrains IDEs, Neovim, Emacs and the other ACP editors: the chat, + tool calls with diffs, the plan, permission prompts (a cancelled or + unknown answer rejects), questions as forms, the modes, the model and + effort, skills as commands, and sessions listed, loaded and resumed. The + backend is chosen when the editor starts it and never switches. + `muse-spark-code-acp auth set` keeps a Model API key in the operating + system's credential store (Windows Credential Manager, the macOS + Keychain, the Secret Service on Linux, with no plaintext fallback); the + key is never read from the environment or passed to Muse Code. Paid + features (web search, image generation) are off unless the editor + starts the agent with `--web-search` or `--image-generation`, and then + each use asks first in the editor's permission prompt, naming the price, + as the panel's popup does (M58): Allow once, Allow always in this + workspace (only with `--trust-workspace`, kept in the agent's data + folder and forgotten when the agent starts without the flag) or Deny. + Subagents stay off in the agent. The agent loads the Model API backend + from the same `dist/modelApi.js` the extension ships (M57), which its + package carries. The editor's MCP + servers (stdio and HTTP) are passed to Muse Code, so Jupyter AI's + notebook tools and Zed's context servers reach it. See `docs/acp.md`; + which editors have been tried is tracked in + `docs/ide-compatibility/hosts.md` (Zed, Emacs + with agent-shell, Neovim with CodeCompanion and JupyterLab with Jupyter + AI so far). On the Model API backend, a trusted folder gets Muse Code's + memory tools as the panel does; subagents, which are paid, are not + offered, and the package carries the C# of the shell tool's Windows job. +- **The ACP agent and proxies.** The agent runs outside VS Code, so VS + Code's proxy and certificate settings do not reach it, and Node's own + `fetch` ignores `HTTPS_PROXY` unless `NODE_USE_ENV_PROXY=1` (Node 22.21 or + later, or 24). The agent does not re-route by itself: on the Model API + backend it warns once in its log at start when a proxy variable is set + and would not be used (or this Node cannot use one), and a request that + never reaches Meta now names the variables to set in the agent's + environment instead of VS Code's `http.*` settings, in all 15 languages. + `docs/acp.md` has a new "Networks and proxies" section. +- **The ACP agent's Muse Code sign-in is read as the panel reads it** + (PR #49): from the credential file's structure, and the CLI's + `account/read` where only it can say, so an agent after `muse logout` + asks for sign-in instead of failing its first turn. A Muse Code agent no + longer clears the Model API agent's "Allow always" when it starts, and a + Model API agent whose `dist/modelApi.js` is missing says to reinstall the + agent, not the extension. +- **The ACP agent keeps credential variables to Muse Code.** A + `META_API_KEY` in the agent's environment still reaches Muse Code and + counts as its sign-in, as with the extension, but no shell command, hook + or git the agent runs sees it or any other `*_API_KEY` variable. A + loaded or resumed session now runs in the mode, model and effort the + editor shows (or fails to load), the model as Muse Code reports it; a + session the agent could not set up is let go rather than left running, + and closing or reloading a session ends its running prompt as + cancelled and stops that turn, with any answer still owed to it + ignored; + a question's form answer is used only + when it is one of the form's own options, in the allowed number; and the + agent's log names a backend failure by its kind, never its message. +- **The key outside VS Code, in the rules** (AGENTS.md rule 8, PLAN.md + D61). Outside VS Code the operating system's credential store stands in + for SecretStorage: the ACP agent's key goes in only through `auth set`'s + standard input and never reaches a child process. The one named + exception is the planned CI bootstrap (M80), whose step shell pipes the + key to `auth set` and unsets it before the run. +- **Open VSX and npm publishing** in the release workflow. A tag also + publishes the VSIX to Open VSX, for VS Code forks that install from + there, and the agent to npm, each only when its token is set in the + `marketplace` environment. +- **Host checks in CI** (the Hosts workflow, `test/hosts/`). Every pull + request that touches the product runs development-extension integration + in VSCodium and packaged browser checks in code-server (the 1.99 floor + and the latest) and Eclipse Theia, + and the packaged ACP agent on Linux, macOS and Windows (its key through + each credential store) and in JupyterLab, Emacs and Neovim, all against + a fake Muse Code CLI; the latest releases are tried again every Monday. + A second workflow, Forks, installs the VSIX in the latest Linux builds + of Cursor, Devin Desktop (formerly Windsurf), Kiro and Positron, then + runs development-extension integration there, weekly and by hand. + ### Changed +- **VS Code 1.99 or newer** (was 1.125; M62, PLAN.md A8), so editors built + on VS Code 1.99 or later can install the extension. The extension uses + no VS Code API newer than 1.85, checked against every published + `@types/vscode` from 1.85 on, and its host bundles now need nothing + newer than Node 20.18, the Node of VS Code 1.99 and 1.100. Tested in + VSCodium 1.99.3 and 1.135 (the integration tests, 9 passing in each) and + in code-server 4.99.4 (VS Code 1.99.3: a conversation and an approval + in the browser), where the 1.125 floor was refused; and in the latest + Cursor, Devin Desktop (formerly Windsurf), Kiro and Positron, which + install it and pass the integration tests. On 1.99 and 1.100 Muse Voice + says it is unavailable, as their Node has no WebSocket. VS Code routes an + extension's WebSocket through its proxy support only from 1.112, so on + 1.101 to 1.111 Muse Voice's socket goes to Meta without it; **Muse Spark: + Diagnostics** now says whether this editor routes the extension's `fetch` + and WebSocket at all, instead of reading only the settings, and the + README's Proxies and certificates section says what to use. The Model + API's requests are routed on every supported version. - **README: How this extension is built** (Development): the owner, Claude Code as lead, up to four headless Muse Code builders on the contributor model, Grok Build and Codex as reviewers, and the gates on dedicated test @@ -40,9 +149,35 @@ happened, not what was planned; superseded entries are kept. the activation bundle (`scripts/check-bundle-split.mjs`). The host-globals and third-party-notices checks cover the new bundle, `npm run cycles` follows it, and the `.vsix` ships it. +- **Build: the ACP agent's budget is 850 KiB** (PLAN.md D6). `dist/acp.js` + is 713.2 KiB now that it loads the Model API backend from + `dist/modelApi.js` (it was 874.1 KiB, over its 800 KiB budget, before + M57 reached it); the budget is that plus about 15 %. The agent is + installed once and never loaded by VS Code, so the size is a download, + not a start-up cost. No other budget changed. ### Fixed +- **ACP paid-grant race tests use canonical temporary paths.** Filesystem + aliases on macOS and Windows no longer leave the test waiting for a rename + under a different name. Native path resolution also expands Windows 8.3 + names. The production grant storage and timeout stay intact. +- **ACP sessions keep the newest owner while cancellation finishes.** A + concurrent reload or close waits for the old turn to stop; an older delayed + resume cannot replace the newest request. A backend exit while a turn starts + fails its prompt without an unobserved rejection terminating the agent. +- **Late ACP answers affect only their owning prompt.** Cancelled or completed + prompts reject late paid-use, approval and question answers; a stale paid + answer cannot install an Allow always grant for a later prompt. +- **ACP paid grants survive independent process updates safely.** Per-feature + revocation generations and generation-specific workspace records replace the + shared JSON map. A stale writer cannot restore revoked permission or replace + a newer explicit grant. Legacy grants ask again; storage that cannot publish + safely remembers nothing and retains only the explicit Allow once use. +- **ACP packaging works from Windows paths containing spaces.** npm runs in + the staging directory with fixed relative arguments. Host documentation now + distinguishes development-extension integration tests from VSIX installation. + - **Signing out of Muse Code finishes, and a signed-out CLI no longer reads as signed in** (PLAN.md D26). - **The cause.** `muse logout` rewrites the CLI's `auth.json` with no diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d89337d9..b0f5f76b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,10 +26,14 @@ Use this order for a candidate branch: 1. Integrate the planned milestones onto the current `main` in order. Resolve conflicts and stage the candidate with no unstaged changes. Record its `git write-tree` hash. -2. Run `npm ci` and `npm run quality` on that exact staged tree. For Windows - behavior, exercise the same tree on Windows (the maintainer uses a - Windows 11 host and VM in parallel) and record the results; collect other - platform evidence where needed. +2. Run `npm ci` and `npm run quality` on that exact staged tree. Before + pushing, test the same candidate on the maintainer's Mac mini, Kubuntu VM + and Windows 11 VM as well as the Windows host. Use isolated checkouts; + record the tree hash, OS and tool versions, commands and process exit + codes. Include full platform gates and the affected real filesystem, + process and packaging paths. Temporary-directory aliases and Windows + short names are part of those paths. An unavailable rig remains a named + blocker; hosted CI does not substitute for the missing local proof. 3. Have an independent agent review the staged diff and acceptance evidence. Scan the staged changes for secrets too: local `security:secrets` scans committed history, so it cannot see the index before commit. Fix findings, diff --git a/PLAN.md b/PLAN.md index 8a27360b..026be111 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,18 +12,18 @@ Status legend: `[ ]` todo · `[~]` in progress · `[x]` done · `[-]` deferred. ## 1. Assumptions -| # | Assumption | Why | Reversal cost | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| A1 | Stack: TypeScript, Node ≥ 22, npm 11, esbuild bundling, React 19 webview. | The VS Code extension API is TypeScript-first; esbuild is Microsoft's documented bundler; React is the de-facto webview framework and what the Claude Code extension appears to use. | Medium (webview components are React-specific). | -| A2 | Package manager is npm with `package-lock.json`, exact version pins (`save-exact=true`). | npm 11.19 is installed; pnpm is not. Exact pins make "latest" impossible by accident. | Trivial. | -| A3 | Extension is **unofficial** and must say so in its name, README and marketplace listing. | Meta Model API ToS / AUP forbid implying Meta endorsement; "Muse Code" and "Muse Spark" are Meta trademarks. | None. | -| A4 | Publisher id `RandyNorthrup` (confirmed 2026-09-22 on marketplace.visualstudio.com/manage: existing publisher, one extension already published). | The marketplace shows the publisher as RandyNorthrup; the URL form is lower-case. | None. | -| A5 | License: MIT. | Standard for VS Code extensions; matches `@muse-code/sdk`. | Trivial before first release. | -| A6 | Settings/command namespace `museSpark.*`, view container id `museSpark`. | Mirrors `claudeCode.*` structure users already know. | Low (rename before first release). | -| A7 | CI provider: GitHub Actions. | `gh` CLI is installed; repo will live on GitHub. | Low. | -| A8 | Target VS Code `^1.125.0` (September 2026 stable is 1.138.0; was `^1.134.0` until 0.1.1). | Needs only long-stable APIs (WebviewView, SecretStorage, `vscode.diff`, `env.openExternal`, `window.createTerminal`). `@types/vscode` publishes only some minors; 1.134.0 was taken at first as the oldest recent one on the registry; on 2026-09-22 a clean VS Code 1.130.0 (the owner's Win11 VM) refused 0.1.0, and 1.125.0 typechecks the whole tree, so the floor is 1.125.0 from 0.1.1. | Trivial. | -| A9 | Pre-commit hooks via **husky + lint-staged**, not the Python `pre-commit` tool. | `pre-commit` is not installed; a Node project should not require a Python toolchain to commit. gitleaks is invoked directly from the husky hook. | Low. | -| A10 | **Fully cross-platform (owner requirement 2026-09-22):** Windows, macOS and Linux are all first-class. Windows is the primary dev machine. | CI matrix runs the full gate set on ubuntu, windows and macos; every OS-specific path (binary discovery, process spawning, paths, line endings) has a unit test per platform branch. Meta documents the `muse` CLI for macOS and Windows; Linux users fall back to the Model API backend if the CLI is unavailable there (Q6). | None. | +| # | Assumption | Why | Reversal cost | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| A1 | Stack: TypeScript, Node ≥ 22, npm 11, esbuild bundling, React 19 webview. | The VS Code extension API is TypeScript-first; esbuild is Microsoft's documented bundler; React is the de-facto webview framework and what the Claude Code extension appears to use. | Medium (webview components are React-specific). | +| A2 | Package manager is npm with `package-lock.json`, exact version pins (`save-exact=true`). | npm 11.19 is installed; pnpm is not. Exact pins make "latest" impossible by accident. | Trivial. | +| A3 | Extension is **unofficial** and must say so in its name, README and marketplace listing. | Meta Model API ToS / AUP forbid implying Meta endorsement; "Muse Code" and "Muse Spark" are Meta trademarks. | None. | +| A4 | Publisher id `RandyNorthrup` (confirmed 2026-09-22 on marketplace.visualstudio.com/manage: existing publisher, one extension already published). | The marketplace shows the publisher as RandyNorthrup; the URL form is lower-case. | None. | +| A5 | License: MIT. | Standard for VS Code extensions; matches `@muse-code/sdk`. | Trivial before first release. | +| A6 | Settings/command namespace `museSpark.*`, view container id `museSpark`. | Mirrors `claudeCode.*` structure users already know. | Low (rename before first release). | +| A7 | CI provider: GitHub Actions. | `gh` CLI is installed; repo will live on GitHub. | Low. | +| A8 | Target VS Code `^1.99.0` (September 2026 stable is 1.138.0; `^1.134.0` until 0.1.1, `^1.125.0` until M62). | Needs only long-stable APIs. M62's audit: the host typechecks against every `@types/vscode` from 1.85 on, and 1.99 is the first release on Node 20.18 and Chromium 132, which the host and webview bundles target. Tested in VSCodium 1.99.3 and code-server 4.99.4 (VS Code 1.99.3), which refused the 1.125 floor (`docs/certification/m62.md`). | Trivial. | +| A9 | Pre-commit hooks via **husky + lint-staged**, not the Python `pre-commit` tool. | `pre-commit` is not installed; a Node project should not require a Python toolchain to commit. gitleaks is invoked directly from the husky hook. | Low. | +| A10 | **Fully cross-platform (owner requirement 2026-09-22):** Windows, macOS and Linux are all first-class. Windows is the primary dev machine. | CI matrix runs the full gate set on ubuntu, windows and macos; every OS-specific path (binary discovery, process spawning, paths, line endings) has a unit test per platform branch. Meta documents the `muse` CLI for macOS and Windows; Linux users fall back to the Model API backend if the CLI is unavailable there (Q6). | None. | ## 2. Resolved decisions @@ -144,7 +144,7 @@ tested on chunk splits inside frames and inside multi-byte characters. | `@testing-library/react` / `dom` / `jest-dom` | 16.3.3 / 10.4.2 / 7.0.1 | Component assertions. | | `@vscode/test-cli` + `@vscode/test-electron` + `mocha` + `@types/mocha` | 0.0.15 / 3.1.0 / 12.0.2 / 10.0.10 | Integration tests inside the Extension Development Host. `@vscode/test-electron` is an unlisted peer of test-cli, so knip ignores it explicitly. | | `esbuild` | 0.28.2 | Bundles extension (cjs, node platform) and webview (esm/iife, browser platform). | -| `@types/vscode` | 1.125.0 | Matches `engines.vscode` (test/unit/manifest.test.ts enforces the pairing). | +| `@types/vscode` | 1.99.0 | Matches `engines.vscode` (test/unit/manifest.test.ts enforces the pairing). | | `@types/vscode-webview` | 1.57.5 | Types for `acquireVsCodeApi()` inside the webview. | | `@types/node` | 22.20.4 | Extension host on VS Code 1.138 is Electron 42 (Node ≥ 22). Typing against 22 keeps code portable to older hosts. | | `@vscode/vsce` | 4.0.0 | Packaging. Needs Node ≥ 22. | @@ -156,6 +156,7 @@ tested on chunk splits inside frames and inside multi-byte characters. | `npm-run-all2` | 9.0.3 | Runs gate scripts in sequence/parallel. | | `rimraf` | 6.1.3 | Cross-platform clean. | | `axe-core` | 4.13.0 | The accessibility gate (M37, D32): WCAG 2.0 to 2.2, levels A and AA, run inside the harness page. MPL-2.0; a dev dependency, never bundled. | +| `playwright-core` | 1.63.0 | The host checks' browser driver (hosts.yml, M62): code-server, Theia and JupyterLab driven in Chrome. Apache-2.0; a dev dependency, never bundled; it uses the installed Chrome, never downloads one. | Deprecated and avoided: `@vscode/webview-ui-toolkit` (archived; npm marks it deprecated). Webview controls are hand-built on VS Code CSS theme variables. @@ -213,6 +214,7 @@ quality`) and as a CI job. | `dist/modelApi.js` | ≤ 400 KiB (M57: the Model API backend, loaded when it first starts; 295.6 KiB when split, see below) | | `dist/searchWorker.js` | ≤ 50 KiB | | `dist/webview/main.js` | ≤ 900 KiB including React, the markdown renderer and highlight.js (one bundle) | +| `dist/acp.js` | ≤ 850 KiB (the ACP agent, installed once, never loaded by VS Code; 713.2 KiB when set, see below) | | `.vsix` | not gated; 0.8.0 is 905,941 bytes (the GitHub Release asset, §10) | `npm run build` prints sizes; `scripts/check-bundle-size.mjs` holds the numbers @@ -270,6 +272,64 @@ instead. stops carrying one of them, or when a file of the folder is on neither the lazy list nor the allowed list above. +**Amendment (PR #32 joined with M57, 2026-09-27): the ACP agent loads the +same `dist/modelApi.js`.** The agent's runtime (`src/runtime/backends.ts`, +D62) builds a `ModelApiBackendManager` per folder, which since M57 needs a +bundle to require. Two ways were open: the agent requires `dist/modelApi.js` +beside `dist/acp.js`, as the extension requires it beside `extension.js`, or +the entry module is bundled into `acp.js` and handed over with `loadBundle`. +The first was taken: + +- **One build of the backend for both packages.** The file the `.vsix` ships + is the file the agent's npm package ships (`scripts/package-acp.mjs` copies + it with `acp.js` and the search worker); bundling the entry into `acp.js` + would have been a second build of the same 20 files, 300 KiB of it. +- **The manager's real path in the agent.** `loadBundle` stays what M57 made + it, a test's way to hand in the source module; the agent exercises the + `require` by path, the missing-file message and the log line, which the + extension otherwise reaches only on the Model API backend. +- **A Muse Code agent never loads the backend**, as a Muse Code user of the + extension never does. +- **Held by the gates.** `check-bundle-split.mjs` reads the agent's metafile + (`dist/meta-acp/acp.json`) too and fails when `dist/acp.js` carries a lazy + file or the entry, so the agent cannot quietly grow a second copy; + `check-host-api.mjs` lists `modelApiEntry.ts` as portable, so the bundle + the agent loads never reaches `vscode`; the agent's third-party notices + take `modelApi.js`'s packages; the stdio e2e suite (and hosts.yml, against + the installed package on three platforms) runs a Model API turn through + the package's own `dist/modelApi.js`. The identity audit of M57 applies to + the agent as it does to the extension: `src/acp` recognises a settled + prompt with `isPromptSettledError`, never `instanceof`. +- **Measured**: `dist/acp.js` went from 874.1 KiB (the joined tree before + M57) to 713.2 KiB; `dist/extension.js` is 432.5 KiB and `dist/modelApi.js` + 299.5 KiB, each under its budget. + +**Amendment (PR #32 joined with M57, 2026-09-27): the ACP agent's budget is +850 KiB.** Set at M63 to 800 KiB against 718 KiB, and outgrown when M48–M56 +reached the agent through the shared managers (874.1 KiB, PLAN.md §7 then). +With the backend in `dist/modelApi.js` the agent is 713.2 KiB; the budget is +that plus about 15 %, rounded up to a multiple of 50 KiB (820 → 850). No +other budget changes. + +- **What it holds** (the production metafile, `dist/meta-acp/acp.json`): + zod 445.2 KiB (62 %), of which 257.6 KiB are its 64 error-message locales, + exported by the classic API `@agentclientprotocol/sdk` imports and so kept + by esbuild although the agent never switches locale; zod's core and + classic API 185.0 KiB; the ACP SDK 53.7 KiB; the English table 58.6 KiB; + the engine the agent runs (the Muse Code backend and its SDK, the tool + harness, the conversation's shared code, `src/acp`, `src/runtime`) the + rest, about 155 KiB. Three of the Model API backend's files, all on + M57's allowed list, stay in it. +- **Why it is acceptable.** The agent is a process of its own that the user + installs once with npm and an editor starts; VS Code never loads it, so + its size adds nothing to the extension's activation, and it is parsed + once per agent start. What it costs is the download: 175.1 KiB of + `acp.js` gzipped, and a 597 KiB package (611,034 bytes, with + `dist/modelApi.js`, the search worker, the 14 tables and the notices). +- **Not taken**: dropping zod's locales. They come with the SDK's own + import of classic zod; removing them means aliasing or patching a + dependency's module graph, for a download a user makes once. + ### D7 — Permission modes map onto MSP approval modes; prompting modes wait for M4 `muse --help` (1.3.0) names the CLI's own modes `untrusted | on-request | @@ -2086,6 +2146,15 @@ status`, and the Model API's prompt cache. What was read or captured first by default). The shipped 1.139.0 extension host carries the same code. So the Model API client and the Muse Voice socket need no proxy client of their own and no dependency. + **Amended 2026-09-27, with M62's 1.99 floor** (`docs/certification/m62.md`, + "The floor after M48–M56"): read again at the tags from 1.99.0 to 1.125.0, + `fetch` is patched at every one (1.99.0: `patchGlobalFetch`, proxy-agent + ^0.32.0), but `WebSocket` only from **1.112.0** (proxy-agent ^0.39.1; the + `http.webSocketAdditionalSupport` setting appears there). On 1.101 to + 1.111 Muse Voice's socket therefore goes to Meta without VS Code's proxy + and certificate handling, and on 1.99 and 1.100 there is no WebSocket at + all. Diagnostics says per global whether the editor routes it (the + global VS Code sets beside each patch), and the README says what to use. - **Node 24.20** reads `NODE_EXTRA_CA_CERTS` when the process starts, for every TLS connection; `--use-system-ca` and `NODE_USE_ENV_PROXY` are process switches an extension cannot set. Its `fetch` throws `TypeError: @@ -2728,19 +2797,293 @@ only the extension-side uses reach it. a documented weak spot, so at most a warning signal. - Any use as the coding model. +### D60 — Muse Spark Code beyond VS Code: the IDE compatibility program (2026-09-26) + +The owner (2026-09-26) handed over a plan, "Muse Spark Code — IDE +Compatibility Plan" (prepared 2026-09-25 against 0.8.0, `bdaede4`), to be +worked beside M45–M56, which the owner is building in parallel. It is kept +whole in `docs/ide-compatibility.md`: the target matrix with its sources, +the architecture, the ACP plan, the release scenarios. Its links were +not re-read here (this environment's network policy blocks them, +2026-09-26); each target's claim is re-read from its source before its +milestone starts, as M41's installers are. + +- **One product, four families.** The VS Code extension qualified in the + editors built on VS Code (VSCodium, Cursor, Kiro, Positron, Theia, + code-server, Codespaces, Che, Firebase Studio); one agent over the Agent + Client Protocol for the editors that host agents in their own chat (Zed, + JetBrains AI Assistant, Xcode 27, Qt Creator, Neovim, Emacs, Sublime, + Devin Desktop); native plugins that embed the React panel (IntelliJ with + Android Studio, Visual Studio, Eclipse, NetBeans, JupyterLab, Spyder, + RStudio); and a terminal or adjacent interface where a host allows no + more. The name stays "Muse Spark Code (Unofficial)" everywhere (rule + 11), and a port never proposes 1.0 (D44). +- **The engine and the UI are shared, the host is an adapter.** `AgentHost` + and `AgentSession` stay the backend boundary; the webview reaches its + host through one bridge; the host's services (workspace roots, documents + and their versions, selection, edits, diffs, diagnostics, terminals, + settings, secrets, persistence, notifications) become a contract with + VS Code's implementation first. VS Code stays the reference client, and + its behaviour does not change while boundaries move. +- **What already holds.** Only 11 source files import `vscode` (M60's + record, after M61 took the logger's); the React app reaches its host + only through `acquireVsCodeApi`, window messages, the text table + embedded in its HTML and 57 `--vscode-*` theme variables; the protocol + is zod-validated both ways. The work is extraction, not a + rewrite. +- **Rulings carried into every adapter.** The key never reaches a child + process, a launch argument, an environment variable, a webview message + or a general IPC field (rule 8, D1): an ACP or native build signs in + through the CLI's own login first, and its Model API backend waits for + the credential-ownership decision (Q63). Paid features stay opt in and + loud (rule 12): an adapter whose client cannot show the price and ask + first offers none. Muse's approval policy is never loosened because a + protocol allows it; a declined or cancelled request never runs by a + translation default. Editing is claimed for a host only after its + backend-specific tests (the plan's §6.1: dirty buffers, exactly-once + changes, undo) pass there. +- **Support is measured, per host.** Integration type (full Muse + interface, native agent interface, external) and release status + (Planned, Prototype, Preview, Supported) are recorded per editor, + version, backend and OS, with each feature tested, partial, + unavailable or unverified. An install is the first step, not the claim. +- **Nothing is installed or billed unasked.** Another IDE is installed + for a probe, and a dependency (the ACP SDK, a JetBrains or .NET + toolchain) is added, only on the owner's go (Q61, Q62) and with rule 9's + checks. Compatibility runs never use the subscription or a paid feature; + a live check follows CLAUDE.md. +- **Numbered from 60**, so M45–M56 keep their numbers while both are built + (M60–M66, Q60–Q64). Moves across files M45–M56 are changing (the + stylesheet, the controller, the protocol) wait until M56 merges; the + inventory and the narrow seams go first. + +### D61 — The Model API key outside VS Code (2026-09-26) + +The owner (2026-09-26): "you need to figure out the proper api key safe +storage". Inside VS Code the key stays in SecretStorage (rule 8). An agent +that another editor starts (D62), and later the native plugins (M64), run +with no VS Code, so the key needs a home of its own. + +- **Rejected**: the operating system's command-line tools as child + processes (`security`, `secret-tool`, PowerShell with DPAPI): the key + would pass through another process, and `security +add-generic-password -w` takes it as an argument, visible to `ps`. A + file encrypted with a key of our own: only as safe as the file's + permissions, and crypto invented here. An environment variable, as many + CLIs take: it invites the key into editor settings files (Zed's agent + `env`, JetBrains' `acp.json`), which rule 8 forbids. +- **Chosen**: the operating system's credential store, reached in-process + through `@napi-rs/keyring` 2.1.0 (MIT, the Node binding of the + `keyring` Rust crate; prebuilt for Windows x64, arm64 and ia32, macOS + x64 and arm64, Linux x64 and arm64 on glibc and musl, arm, riscv64, + FreeBSD; published 2026-09-13, outside the 7-day window). Windows + Credential Manager (DPAPI, per user), the macOS login Keychain, and on + Linux the Secret Service (GNOME Keyring, KWallet, KeePassXC), pinned + with `linux: { store: 'secret-service' }`: the kernel keyring the + library would otherwise fall back to forgets the key at reboot. Without + a Secret Service the Model API backend says how to get one; there is no + plaintext fallback. +- **One entry per user**: service `Muse Spark Code (Unofficial)`, account + `museSpark.modelApiKey` (the key's name in the extension's + SecretStorage), shared by every editor that runs the agent. A missing + entry reads as `null` from the binding, whatever its typings say (found + against GNOME Keyring at M63); the store turns it into `undefined`. +- **Setting it**: `muse-spark-code-acp auth set` reads the key from the + terminal with echo off (or one line from a pipe), checks its shape, + stores it and prints only that it did; `auth status` says whether a key + is stored, never any of it; `auth clear` removes it. Each ACP client is + offered a terminal sign-in that runs exactly `auth set`, so the key goes + from the keyboard to the store without passing through the editor. +- **Never**: an argument, an environment variable, a file, a log (the + redactor stays), an ACP message, or a child process: not the environment + of `muse serve` (D1), a tool or a check. +- **Credential variables** (the Codex review of `a209130`, 2026-09-28): + a `META_API_KEY` in the agent's own environment is the user's for Muse + Code, as the extension's `muse serve` inherits it (D1's amendment), and + counts as Muse Code's credential there; the agent takes it and every + other credential variable (`*_API_KEY`, the names hooks never get) out + of its own environment at start (`src/runtime/credentialVariables.ts`) + and hands them back to Muse Code's processes only, so a shell command, + a hook, git or a helper never sees one. +- **AGENTS.md rule 8 (amended 2026-09-28)** names this store as + SecretStorage's stand-in outside VS Code (it is the store SecretStorage + itself rests on), filled only through `auth set`'s standard input and + never passed to a child process. Its one named exception is M80's CI + bootstrap: GitHub hands a secret to a step only through its environment + or script, so the Action's step shell is the one environment the key is + ever in; it pipes the key to `auth set` and unsets it before `exec` + starts. +- **Later**: offering, in VS Code, to copy the key into the OS store for + the other editors needs the native module in the `.vsix`, so + per-platform packages (with M64). + +### D62 — The ACP agent, and the order the editors come in (2026-09-26) + +The owner (2026-09-26): "the top editors come first but i want them all or +as close to all as possible", and approved the ACP SDK. One agent over the +Agent Client Protocol reaches the most editors for the least code (Zed, +the JetBrains IDEs through AI Assistant, Xcode 27, Qt Creator, Neovim, +Emacs, Sublime Text, Devin Desktop), so it comes first. + +- **One executable**, `muse-spark-code-acp` (an npm package of that name; + its bin is `dist/acp.js`, Node 22 or later), speaking ACP v1 on stdio + through `@agentclientprotocol/sdk`, which parses every inbound frame + against the protocol's schema before a handler runs (rule 7). stdout is + the protocol; the log goes to stderr, redacted. +- **The panel's engine, not a second one**: the backend managers of + `src/host/backend` (portable since M61), the same `AgentHost`, + `AgentSession` and `AgentEvent`s. The ACP code lives in `src/acp` and the + process wiring in `src/runtime`, both under the M60 gate's portable + roots. +- **The backend is chosen at launch**: `--backend museCode` (the default: + the CLI signed in on its own, the subscription pays) or `--backend +modelApi` (the key of D61). There is no "auto", so the bill is never a + surprise; a user who wants both configures two agents. +- **What maps to what**: messages to `agent_message_chunk`; reasoning to + `agent_thought_chunk`; tool calls to `tool_call` and `tool_call_update` + (kind, title, locations, arguments, output, diffs for edits); a subagent + to a tool call; the todo list to a `plan`; approvals to + `session/request_permission` with the backend's own choices (allow or + deny, once or for the session: `allow_once`, `allow_always`, + `reject_once`, `reject_always`); permission modes to session modes; + model and effort to config options; skills to available commands; the + session's name to `session_info_update`; context use to `usage_update`. +- **Nothing runs by a translation default**: a permission request the + client cancels, or answers with an option it was not offered, is decided + with the backend's deny choice. A question the agent asks goes to the + client's elicitation form where it has one; otherwise the question is + shown as text and declined, so the model carries on and the user answers + in the next prompt. +- **Sessions**: new, load (the history replayed as updates), list, resume, + and fork where the backend allows it (`canEditSessions`). +- **Prompts**: text, resource links (as @mentions), embedded text + resources (as context), images (checked by their headers, as + attachments are). +- **Sign-in**: `initialize` offers the chosen backend's sign-in, "Sign in + to Muse Code" (the agent's `login`, which runs `muse login`) or "Store a + Meta Model API key" (`auth set`, D61), as a terminal method to a client + that runs them and as a command to run by hand to one that does not; + `session/new` answers `auth_required` until the chosen backend has its + credential. Muse Code's is read as the panel reads it (D26, PR #49): the + credential file's structure, and `account/read` where only the CLI can + say; `authenticate` asks afresh. +- **Load and resume run as advertised** (the Codex review of `a209130`): + a loaded or resumed session gets the permission mode, a listed model and + the effort the agent shows, or the load fails; the agent never shows a + mode stricter than the one in force. What it shows comes from the + backend's answer or an explicit set, never from a value the session's + handle merely holds (Codex on `4eb0156c`: Muse Code's resumed handle + holds the model asked for, the CLI the one last used): the model is + the one `model/list` reports active for the session where the agent + lists it, else the default, set. A session is held, and its id + answered, only once it is set up; a new, loaded or resumed session + whose setup fails is let go. A session loaded again lets the one held + go first, and one still being set up by an earlier load, as both hosts + hand the same session back; a close lets both go. A session let go + changes nothing more on the backend, sends the editor nothing more, + decides nothing on the editor's late answers (a paid use is denied), + and its running prompt ends cancelled with its turn stopped on the + backend once its start is answered (even a failed start, which past + its deadline may still begin). A reload follows the session only once + the held one's turn is stopped; the backend stopping lets go of loads + being set up too. The client's answers to the + agent's own requests (permission, elicitation) are parsed with zod, and + a form answer must be one the form allowed (its options, how many), or + the question is declined. The agent's log names a backend failure by + its kind (`failureForLog`, PR #49), never by its message. +- **Trust**: a folder's rules, skills and memory load only with + `--trust-workspace`, the flag Muse Code itself takes (D13); ACP carries + no workspace trust of its own. +- **Paid features are off in the agent** (rule 12, D60) until a + confirmation over `session/request_permission` that names the price is + built and certified. **Built 2026-09-26 (M63c):** `--web-search` and + `--image-generation` (Model API backend only) let the first prompt ask + for each, with the panel's title and price; only "Turn on" turns it on, + for the life of the process, and anything else leaves it off without + asking again. Paid rows and approvals name their price; Muse Voice has + no microphone in the agent. +- **Amended 2026-09-27 (M58 joined, D48): every paid use asks in the + editor.** The owner's rule, "anything requiring extra payment should + prompt … allow once, allow always in this workspace, or deny", holds in + the agent too, through the core's `PaidUseConsent` and the panel's own + words (`paidUseQuestion`, moved beside it so the modal and the agent say + the same): + - A feature is on only with its flag (`subagents`, scheduled prompts and + Muse Voice have none, so they are always denied); each use then asks + over `session/request_permission` in the conversation it is for: a row + naming what is about to be billed and its price, and the options + `allow_once`, `allow_always` and `reject_once`. Web search asks once per + prompt, an image before it is bought, as in the panel. The first + prompt's "Turn on" question is gone: the price is named every time + instead, and asking twice before one prompt would say the same thing + twice. + - The Model API host serves every conversation of a folder, so its + `allowsPaidUse` now names the conversation (`sessionId`, a child task's + being its parent's); the agent asks in that ACP session and denies when + it holds no such session or has no client to ask. A cancelled prompt, + an option it did not offer, or a failed request is Deny. + - "Allow always in this workspace" is offered and honoured only with + `--trust-workspace`, as the panel offers it only in a trusted workspace. + It is kept per folder in the agent's data folder (`acp/paid-uses.json.d`, + per-feature generations and workspace-hash/generation grant records, + `src/runtime/paidGrants.ts`), read at every question so other agent + processes' changes count. Independent records and atomic generation + revocation prevent cross-process stale writes from restoring a revoked + grant or replacing a newer one; the earlier JSON map is ignored and asks + again. A filesystem that cannot safely publish the generation remembers + nothing and keeps the explicit use as Allow once. The grant + lapses in every folder when the agent starts without that feature's + flag, so turning the flag on again asks again (the panel's grant + generation, D48, in the agent's terms). +- **Networks (Q66, 2026-09-27): loud, not re-routed.** VS Code's proxy and + certificate settings do not reach the agent, and Node's `fetch` uses the + environment's proxy only with `NODE_USE_ENV_PROXY=1` (Node 22.21+, 24+). + The agent does not turn that on or add a proxy agent of its own; it logs + one warning at start when `HTTPS_PROXY`, `HTTP_PROXY` (either case) is set + for the Model API backend and the switch is off or this Node lacks it, and + a request that never reaches Meta names the agent's environment + (`NODE_USE_ENV_PROXY`, `HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, + `--use-system-ca`) rather than VS Code's `http.*` settings. Muse Code, + started by the agent, reads the proxy variables itself. +- **Tools run in the agent**, as ACP allows. Routing the Model API + backend's file reads and writes through the client (`fs/*`), so an + unsaved buffer is seen and never overwritten, is a later step, with its + own tests (the owner's plan, §6.1). +- **Where it is tested**: against the SDK's own client in-process and + over a real stdio pipe to the built `dist/acp.js`, with the fake Muse + Code CLI (`test/e2e`); in editors as each can be installed. This + container reaches npm, PyPI, Maven Central, Gradle, NuGet, Ubuntu's + archive and download.eclipse.org, and not JetBrains, Microsoft's + VS Code downloads, Open VSX, Zed's site or neovim.io (2026-09-26). +- **The order**: the most-used editors first. VS Code's family (Cursor, + Windsurf, VSCodium, Kiro, Positron, Theia, code-server, Codespaces) + through the `.vsix` and Open VSX (M62); the JetBrains IDEs, Zed, Neovim, + Emacs, Xcode 27, Qt Creator, Sublime and Devin through this agent (M63); + then Visual Studio and the JetBrains full panel (M64); Eclipse, + NetBeans, Jupyter, Spyder and RStudio (M65); and the constrained hosts + (M66). `docs/ide-compatibility/hosts.md` tracks each editor's route and + status. + ## 3. Open questions (need the owner) -| # | Question | Default until answered | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| Q1 | **Resolved 2026-09-22:** owner authorised installing anything needed; Muse Code CLI 1.3.0 installed via the official installer. The owner reported Muse Code CLI device sign-in and a pay-as-you-go Model API key; M7 keeps them apart (D1 amendment). A separate current Muse Code paid entitlement or tier is unverified; the personal Muse Power/Maximum screenshot is not CLI entitlement proof. | Closed. | -| Q2 | **Resolved 2026-09-22:** publisher `RandyNorthrup` read from the signed-in marketplace management page. Display name stays "Muse Spark Code (Unofficial)" unless the owner asks otherwise. | Closed. | -| Q3 | **Resolved 2026-09-22:** owner wants both the CLI (MSP) backend and the Model API backend in the first release. M7 is required for v0.1.0. | M7 required; see §10. | -| Q4 | **Resolved 2026-09-22 (M9, superseding the M8 answer):** voice dictation ships through the operating system's own recogniser, at no API cost and with no third-party code (owner's constraints): Windows PowerShell 5.1 + `System.Speech` on Windows, a Swift helper on Apple's Speech framework on macOS (owner chose this over an `osascript` bridge), and a dimmed button with the reason on Linux (no distribution ships a recogniser; the owner may revisit). The M8 finding stands for the webview itself: Electron's Web Speech recogniser is dead, so recognition runs in a helper process. | Closed; see M9. | -| Q5 | **Resolved (M4):** `highlight.js` 11.12.0 core with a fixed language set, in the webview bundle; `shiki` was not taken (grammar weight). | Closed. | -| Q6 | **Partly answered (M55):** Meta's Muse Code overview (checked 2026-09-25) documents `curl -fsSL https://dev.meta.ai/install.sh \| sh` for macOS and Linux, and the panel offers it; `muse` itself has not been run on Linux. | Offer the installer; the Model API key remains the fallback if `muse` is absent. | -| Q7 | **Resolved 2026-09-22:** owner pressed F5 and confirmed the Muse Spark chat shell renders in the Extension Development Host (verbal confirmation; no screenshot filed). | Closed. | -| Q8 | **Resolved 2026-09-22:** owner signed in; publisher is `RandyNorthrup`. Publishing ran by hand from the CI artifact with a clipboard PAT for 0.1.0–0.5.0; since 2026-09-23 the `VSCE_PAT` repository secret lets `release.yml` publish every `v*` tag. | Closed. | -| Q9 | The Muse Code user rules file: `/rules import` writes one into the config root and the model is told "if user and project rules conflict, project rules win", but its file name is not printed by `muse --help`, `muse skills`, the settings skill or the binary's strings. The Model API backend cannot mirror what it cannot name. | Not loaded on the Model API backend; the CLI backend loads it itself. | +| # | Question | Default until answered | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Q1 | **Resolved 2026-09-22:** owner authorised installing anything needed; Muse Code CLI 1.3.0 installed via the official installer. The owner reported Muse Code CLI device sign-in and a pay-as-you-go Model API key; M7 keeps them apart (D1 amendment). A separate current Muse Code paid entitlement or tier is unverified; the personal Muse Power/Maximum screenshot is not CLI entitlement proof. | Closed. | +| Q2 | **Resolved 2026-09-22:** publisher `RandyNorthrup` read from the signed-in marketplace management page. Display name stays "Muse Spark Code (Unofficial)" unless the owner asks otherwise. | Closed. | +| Q3 | **Resolved 2026-09-22:** owner wants both the CLI (MSP) backend and the Model API backend in the first release. M7 is required for v0.1.0. | M7 required; see §10. | +| Q4 | **Resolved 2026-09-22 (M9, superseding the M8 answer):** voice dictation ships through the operating system's own recogniser, at no API cost and with no third-party code (owner's constraints): Windows PowerShell 5.1 + `System.Speech` on Windows, a Swift helper on Apple's Speech framework on macOS (owner chose this over an `osascript` bridge), and a dimmed button with the reason on Linux (no distribution ships a recogniser; the owner may revisit). The M8 finding stands for the webview itself: Electron's Web Speech recogniser is dead, so recognition runs in a helper process. | Closed; see M9. | +| Q5 | **Resolved (M4):** `highlight.js` 11.12.0 core with a fixed language set, in the webview bundle; `shiki` was not taken (grammar weight). | Closed. | +| Q6 | **Partly answered (M55):** Meta's Muse Code overview (checked 2026-09-25) documents `curl -fsSL https://dev.meta.ai/install.sh \| sh` for macOS and Linux, and the panel offers it; `muse` itself has not been run on Linux. | Offer the installer; the Model API key remains the fallback if `muse` is absent. | +| Q7 | **Resolved 2026-09-22:** owner pressed F5 and confirmed the Muse Spark chat shell renders in the Extension Development Host (verbal confirmation; no screenshot filed). | Closed. | +| Q8 | **Resolved 2026-09-22:** owner signed in; publisher is `RandyNorthrup`. Publishing ran by hand from the CI artifact with a clipboard PAT for 0.1.0–0.5.0; since 2026-09-23 the `VSCE_PAT` repository secret lets `release.yml` publish every `v*` tag. | Closed. | +| Q9 | The Muse Code user rules file: `/rules import` writes one into the config root and the model is told "if user and project rules conflict, project rules win", but its file name is not printed by `muse --help`, `muse skills`, the settings skill or the binary's strings. The Model API backend cannot mirror what it cannot name. | Not loaded on the Model API backend; the CLI backend loads it itself. | +| Q60 | **Answered 2026-09-26:** the owner set up the Open VSX account: the Eclipse Publisher Agreement signed, the namespace `RandyNorthrup` created, the token in `OVSX_PAT`. The release workflow publishes there from the next tag (M62). | +| Q61 | **Resolved 2026-09-26:** the owner approved the ACP SDK. `@agentclientprotocol/sdk` 1.4.0 is pinned: 1.5.0 (2026-09-21) is inside `.npmrc`'s 7-day `min-release-age`, and 1.4.0 speaks the same ACP v1 (D62). | +| Q62 | **Resolved 2026-09-26:** "you can install whatever you need". What this container's network lets in is recorded per editor (D62); the rest is qualified in CI or on the owner's machines. | +| Q63 | **Resolved 2026-09-26:** the owner left the design to us: D61, the operating system's credential store, in-process. | +| Q64 | **Resolved 2026-09-26:** "the top editors come first but i want them all or as close to all as possible": the order is D62's. | +| Q65 | **Answered 2026-09-26:** after npm held the owner's account for suspicious activity, the owner set `NPM_TOKEN` in the `marketplace` environment. The name `muse-spark-code-acp` was free that day; the next tag publishes it, and each GitHub Release still carries the package. | +| Q66 | **Resolved 2026-09-27: loud, not re-routed.** The ACP agent's own requests (the Model API backend) use Node's `fetch`, which ignores `HTTPS_PROXY` unless `NODE_USE_ENV_PROXY=1` (Node 22.21+ or 24+; measured on seven releases). The owner: the agent does not re-route by itself or add undici. It warns once at start, in its log, when a proxy variable is set for the Model API backend and Node's switch is off or missing (`src/runtime/proxyWarning.ts`), and a request that never reaches Meta gets advice naming the agent's environment variables instead of VS Code's `http.*` settings (M56's classifier, told by the runtime which host it serves: `networkAdvice: 'agent'`). | Closed; `docs/acp.md` "Networks and proxies", `docs/certification/pr32-integration.md`. | ## 4. Architecture @@ -6966,10 +7309,10 @@ harness scenario, which is what the accessibility gate checks (D32). - a prompt in, JSONL events or a final JSON out; - a schema for the output; - a budget, kept by reservation as in M82, and an attempt cap. - - M63 and D61 are defined in PR #32, which is not merged yet; M80 is - blocked until it is. PR #32 also amends AGENTS.md rule 8 to name the OS - credential store as the store outside VS Code (it is the one - SecretStorage itself uses). + - M80 builds on M63 and D61 (PR #32), and on PR #32's amendment of + AGENTS.md rule 8, which names the OS credential store as the store + outside VS Code (the one SecretStorage itself rests on) and this + bootstrap as its one exception. - A GitHub Action for PR review and "fix this" comments, on the user's own runners and key. - It runs only for triggers from the repository's owners, members and @@ -7238,8 +7581,308 @@ The CLI itself is not bundled: it is Meta's closed-source binary. test builds a manager of its own over the same path. The live sweep now loads `dist/modelApi.js` and was not run for this milestone (no live calls). +### M60–M66 — Muse Spark Code beyond VS Code (D60) + +**Status 2026-09-26: the program the owner handed over** +(`docs/ide-compatibility.md`), built beside M45–M56. The phases are the +plan's §8 (A–G); a phase that needs another editor installed waits for Q62. + +| Milestone | Phase | What it delivers | +| --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| M60 | A | The host API inventory: every VS Code API, Node built-in, webview host call and theme variable the extension uses, recorded and gated; the `vscode` boundary | +| M61 | B | Shared boundaries: the webview's host bridge, the surface and controller free of VS Code types, theme tokens, the editor-services contract, a Node runtime | +| M62 | A, C | The VS Code family: probes and qualification in VSCodium, Cursor, Kiro, Positron and Theia; code-server and Codespaces profiles; Open VSX (Q60) | +| M63 | D | The ACP agent: `muse-spark-code-acp` on ACP v1 (Q61); Zed, then one JetBrains IDE (Q64), then Xcode 27, Qt Creator, Neovim, Emacs, Sublime and Devin | +| M64 | E | Native full interfaces: the IntelliJ plugin on JCEF with Android Studio qualified separately; Visual Studio on VSSDK and WebView2 | +| M65 | F | Eclipse, NetBeans, JupyterLab 4 and Notebook 7, then Spyder and RStudio | +| M66 | G | Conditional hosts: vscode.dev and github.dev, Xcode 26.3's external route, Vim, Kate, MATLAB, Replit, StackBlitz, CodeSandbox and Ona; the companion's media | + +### M60 — The host API inventory and the `vscode` boundary (D60, phase A) + +**Status 2026-09-26: built and certified** (`docs/certification/m60.md`). + +- **Goal**: know exactly what the extension asks of its host, so each + editor in D60's matrix can be checked against it (Theia's API + comparator, a fork's VS Code version, a browser engine), and keep that + knowledge true as the code changes. +- **Scope**: `scripts/check-host-api.mjs` (`npm run check:host-api`, in + `quality:gates`; `--write` regenerates the record): with TypeScript's + checker, every VS Code API the host uses at run time (functions, + variables, classes, enums, members of VS Code objects) and the files + that use it; the files that import `vscode`; the Node built-ins the host + imports; the webview's host calls (`acquireVsCodeApi`) and the + `--vscode-*` theme variables it reads; the manifest's facts (engines, + `extensionKind`, entry points, capabilities, activation events, + contribution points). The record is `docs/ide-compatibility/host-api.md`, + formatted as Prettier would; the gate fails when it differs from the + source. Whatever the record says, the portable code never reaches + `vscode` through its imports, type-only ones included: everything under + `src/core`, `src/shared` and `src/webview`, and the host modules the + script lists (the conversation controller, both backend managers, the + tool harness, the credential and session stores, the `ide` server). A + VS Code object handed to portable code by shape (the log channel, + `SecretStorage`) is still recorded, member by member, where it is handed + over. +- **Acceptance**: a red drill for each failure; the gate green; the record + read through. +- **Not here**: installing another editor (M62, Q62). + +### M61 — Shared boundaries (D60, phase B) + +**Status 2026-09-26: the first two steps built** (`docs/certification/m60.md` +records them with M60); the rest waits for M56 to merge. M56 merged +(PR #44) and was joined into this branch on 2026-09-27; steps 3, 4 and 6 +are no longer blocked by it. + +- **Goal**: the engine and the React UI can be driven by a host other than + VS Code, while VS Code behaves exactly as before. +- **Scope, in order**: + 1. The webview's host bridge (`src/webview/hostBridge.ts`): posting to + the host, the saved state and the host's messages go through one + interface, and `main.tsx` no longer calls `acquireVsCodeApi` itself. + **Built.** + 2. `ChatSurface` and `ConversationMessage` in a module with no `vscode` + type (`src/host/views/chatSurface.ts`), the logger taking its channel + by shape and `DictationSetup` in the core, so the conversation + controller, both backend managers and the other modules the M60 gate + lists are portable. **Built.** + 3. Theme tokens: the stylesheet reads `--muse-*` tokens mapped once from + VS Code's variables (M60's record lists the 57), so another host maps + its own; the harness screenshots identical before and after. After + M56. + 4. The editor-services contract (D60's list), taken by the controller + and the Model API tool harness, with VS Code's implementation. After + M56. + 5. A standalone Node runtime entry that drives `AgentHost` without + `vscode`, tested against the fake CLI and the protocol captures. + **Built with M63a** (`src/runtime/`, on the M60 gate's portable + list), driven over stdio against the fake CLI. + 6. Capability detection: what a host offers, and what the UI hides or + explains when it does not. +- **Acceptance**: every gate and the integration tests unchanged; each + moved module on the M60 gate's portable list. + +### M62 — The VS Code family (D60, phases A and C) + +**Status 2026-09-26: M62a built and certified** +(`docs/certification/m62.md`); the other forks and the first Open VSX +listing are M62b. + +- **Goal**: every editor built on VS Code installs and runs the extension + as it is, from a `.vsix` or Open VSX, as far back as the code allows. +- **M62a, the floor**: `engines.vscode` from `^1.125.0` to `^1.99.0`, on an + audit and real-host tests, as the owner's plan asks + (`docs/ide-compatibility.md` §3.1). + - The VS Code API: the host, unit and integration projects typechecked + against every published `@types/vscode` from 1.85 to 1.120. The host + needs nothing newer than 1.85; the unit tests' panel fake needed 1.96 + (`IconPath`) and 1.108 (its shape) and is now typed from the interface. + - Node: VS Code's own pins (`remote/.npmrc`, the Electron target) give + Node 20.18.3 for 1.99, 20.19.0 for 1.100 and 22.15.1 from 1.101. + Compiled against `@types/node` 20.19 and the ES2023 library, the host + used one newer API, `Promise.withResolvers`, now replaced. The host + project's library is ES2023 and its bundles target `node20.18`; the + ACP agent keeps `node22`, run by the user's own Node. + - Why 1.99: the first release on Node 20.18 and Chromium 132 (Electron + 34). Older releases run Node 20.9 to 20.16 and Chromium 122 to 130, + which neither the host nor the webview (`chrome128`) was checked + against; going lower waits for a named editor that needs it. + - Muse Voice needs a global `WebSocket`, which Node 20 lacks; on 1.99 + and 1.100 it says so (M35's check) and dictation's other routes stay. + - Tested: VSCodium 1.99.3 and 1.135 run the integration tests (9 each); + code-server 4.99.4 (VS Code 1.99.3, Node 20.18.3) installs the VSIX, + activates it and runs a conversation and an approval against the fake + CLI in the browser, and refuses the same VSIX with the 1.125 floor. + CI's `minimum` integration run now downloads 1.99.0. + - **After M48–M56 joined (2026-09-27)** (`docs/certification/m62.md`, + "The floor after M48–M56"): the host still typechecks at 1.99's + `@types/vscode` and at `@types/node` 20.19 with ES2023, once M55's two + `Promise.withResolvers` in the device sign-in became a plain promise; + the host API record was regenerated (198 APIs, as before; 13 files + import `vscode`). M56 relied on VS Code routing an extension's `fetch` + and `WebSocket`: `fetch` is routed at every version from the floor, + `WebSocket` only from 1.112.0, so on 1.101 to 1.111 Muse Voice's socket + goes out without VS Code's proxy and certificate handling. Diagnostics + now says per global whether the editor routes it, and the README says + so. The ACP agent's backends took M48–M56's new inputs: Muse Code's + default network sandbox, the in-memory prompt cache, M49's memory tools + in a trusted folder, no subagents (paid, and the agent has no flag for + them), and the shell job's C# shipped in its package. +- **M62b, the forks**: Cursor, Windsurf, Kiro, Positron, Theia (from npm), + Firebase Studio, Che and Codespaces, each installed where it can be and + its version recorded in `docs/ide-compatibility/hosts.md`; the Open VSX + listing after the next tag. + - **Theia 1.75, 2026-09-26** (`docs/certification/m62.md`): built from + npm as a browser app; it claims VS Code API 1.134, so the floor is no + obstacle. The panel in a tab and the sidebar run a conversation and an + approval against the fake CLI. Found: Theia never fires `onView:` for a + webview view (it fires only for a view with no child widget, and a + webview view gets its widget at once), so the sidebar opened first + stays blank until a command or the tab starts the extension. Not + worked around with `onStartupFinished`, which would start the + extension, and read SecretStorage, in every VS Code window; README's + Troubleshooting gives the shortcut. The fix belongs in Theia. + - **Forks in CI, 2026-09-26** (`.github/workflows/forks.yml`, + `docs/certification/m62.md`): Cursor, Devin Desktop (Windsurf's new + name), Kiro and Positron at their latest Linux builds, found through + their own update feeds as nixpkgs and Homebrew find them; the VSIX + installed with each fork's CLI and the integration tests run in it, + weekly and by hand. Their feeds are refused in the container; the + first run on GitHub's runners passed in all four: Cursor 3.22.7 (VS + Code 1.128), Devin Desktop 3.10.35 (1.126), Kiro 1.1.70 (1.131) and + Positron 2026.09.1 (1.130), 9 integration tests each. +- **Acceptance (M62a)**: every gate green with the floor's types; the + integration tests on a 1.99 host; drills for the API and Node checks. + +### M63 — The ACP agent (D62, phase D) + +**Status 2026-09-28: M63a built and certified** +(`docs/certification/m63.md`); M63b run in Emacs, Neovim, Zed and +JupyterLab, and in CI (below); M63c's MCP servers and paid features built, +joined with M57, M58 and PR #49's sign-in +(`docs/certification/pr32-integration.md`). + +- **Goal**: Muse Spark in every editor that hosts agents over ACP, on + both backends, with the panel's approvals and none of its bills + unannounced. +- **M63a, the agent**: `src/acp` (the translation of D62) and + `src/runtime` (the process: arguments, the stderr log, the two backend + managers, the OS key store of D61, the data folder for Model API + sessions); `muse-spark-code-acp` with `auth set|status|clear` and + `login`; the esbuild entry `dist/acp.js` and its budget; the npm package + and its tarball on each GitHub Release; tests against the SDK's client + in-process and over stdio to the built agent with the fake Muse Code + CLI; README configuration for each client; drills. +- **M63b, the clients**: each ACP client installed and driven where it + can be (Neovim with CodeCompanion, Emacs with agent-shell, Zed, + a JetBrains IDE, Qt Creator, Xcode 27, Sublime, Devin Desktop), its + version and results recorded in `docs/ide-compatibility/hosts.md`. + - **Emacs, 2026-09-26** (`docs/certification/m63.md`): Emacs 29.3 from + Ubuntu, acp.el 0.15.2, shell-maker 0.97.3 and agent-shell 0.79.2 + fetched file by file (GitHub's archives are refused here). acp.el + alone, and agent-shell in batch, ran the agent against the fake CLI: + the modes, the model and effort, a streamed reply, a tool call allowed + (`y`) and one rejected (`C-c C-c`, which cancels the turn, so the + permission answer is `cancelled` and the call is rejected). The + agent-shell configuration is in `docs/acp.md`. + - **Neovim, 2026-09-26**: Neovim 0.11.4 (its GitHub release), + plenary.nvim and CodeCompanion v19.25.0 (cloned; the tag was ten days + old), headless: a streamed reply, then CodeCompanion's approval prompt + (Accept `g2`, Reject `g3`, Cancel `g4`) pressed in the chat buffer: + accepted, the command ran; rejected, it was skipped. The adapter is in + `docs/acp.md`. + - **Zed, 2026-09-26**: Zed 1.20.2 from its GitHub release (zed.dev is + refused here), run as an unprivileged user on Xvfb with Mesa's + software Vulkan and driven with xdotool. Muse Spark appeared under + External Agents; its thread showed the model and effort selectors, + streamed the reply, and ran or skipped a command from Zed's permission + card (Allow once, Reject). Zed now needs `"type": "custom"` in + `agent_servers`, which `docs/acp.md` lacked; fixed. + - **JupyterLab, 2026-09-26**: Jupyter AI 3.2.0 ships an ACP client + (`jupyter-ai-acp-client` 0.3.0) that runs agents as chat personas, so + JupyterLab 4 is reached through the agent now rather than waiting for + M65's native extension. With JupyterLab 4.6.3 (4.6.4 was five days + old) and a local persona file, the chat showed the agent's model, mode + and effort pickers and its context gauge, and allowed and rejected a + command from Allow once / Reject buttons. Found: Jupyter AI passes its + notebook tools as MCP servers (HTTP ones only to an agent advertising + `mcpCapabilities.http`) and prepends a note telling the model to use + them; the agent passes no MCP servers on yet, so M63c's MCP item + matters here first. + - **In CI, 2026-09-26** (`.github/workflows/hosts.yml`, `test/hosts/`, + `docs/certification/m63.md`): JupyterLab, Emacs (acp.el v0.15.1 and + agent-shell v0.77.4, the newest tags seven days old) and Neovim run + the packaged agent against the fake CLI on each pull request, with + VSCodium, code-server and Theia for the extension, and the agent's + package and key store on all three platforms. Zed stays manual. +- **M63c, the rest of the protocol**: file reads and writes through the + client (`fs/*`) for the Model API backend; paid features with a + confirmation that names the price; `session/close` and `delete`; the ACP + Registry once Q65 is answered; the editor's MCP servers. + - **MCP servers, 2026-09-26** (`docs/certification/m63.md`): the engine's + per-session servers gain a stdio kind beside HTTP (MSP takes both), and + the agent passes the editor's stdio and HTTP servers to Muse Code on + `session/new`, `load` and `resume` when the host granted `sessionMcp`, + each optional; it advertises `mcpCapabilities.http` on that backend. + SSE and the unstable ACP transport are left out, the Model API backend + runs none, and only server names are logged (headers and environments + can hold secrets). JupyterLab's notebook tools now reach the agent. + - **Paid features, 2026-09-26** (`docs/certification/m63.md`): web + search and image generation behind `--web-search` and + `--image-generation` on the Model API backend, each confirmed in the + editor at the first prompt with its price (`src/acp/paid.ts`); a + cancel while the price is asked ends the prompt without a turn. + File access through the client (`fs/*`) waits for M46–M56, since it + needs the session threaded through the Model API backend's tools. + - **Joined with main's M57 and M58, 2026-09-27** + (`docs/certification/pr32-integration.md`): the agent loads the + Model API backend from the extension's own `dist/modelApi.js`, which its + package ships (D6 amendment), and each paid use asks in the editor with + Allow once, Allow always in this workspace (with `--trust-workspace`, + kept in the agent's data folder) or Deny, replacing the first prompt's + price question (D62 amendment). +- **Acceptance (M63a)**: a session created, prompted, streamed, cancelled, + asked for permission (allowed, denied, cancelled), loaded and listed + over stdio on the Muse Code backend (fake CLI), and on the Model API + backend (fake server) in process through the same runtime backend, + because the process reads the key only from the OS store; `auth set`, + `status` and `clear` against a real Secret Service; `auth_required` + before sign-in; the key never in a frame, an argument, the environment + or the log; every gate green. + ## 7. Gates +**PR #32 final review reopened (2026-09-29, head `46ba5406`):** independent +reviews found a pending-release/reload ownership race in the ACP session state, +cross-process whole-file paid-grant updates that can resurrect a revoked grant, +and an unquoted absolute staging argument in Windows npm packaging. Repair +session ownership across awaited release, replace shared grant read/modify/write +with authoritative independent records and revocation, and run npm packaging +from its staging working directory with fixed relative arguments. Add realistic +regressions and red/restored proofs; correct VSCodium/fork evidence wording to +distinguish development-extension integration from packaged installation. +`docs/certification/pr32-integration.md` records findings, fixes and fresh gates. +The same review also found an unobserved prompt-completion rejection when the +backend exits while a turn starts, and a late paid-use answer that can install +an always grant after its prompt was cancelled. Observe completion failures from +creation and bind permission answers to the owning active prompt, with SDK and +real MSP/session regressions for these lifecycle siblings. + +**Merge-completion goal (owner authorization, 2026-09-29):** complete and +properly merge the existing cloud-work branches and open PRs into main by the +end of the owner's day (America/Los_Angeles). Start with PR #32's final review; +then choose integration order from dependencies and verified readiness. Parallel +agents may review and fix separate worktrees; one integrator owns merges and +aggregate gates. Preserve existing work and the D49 acceptance contracts. Fix +findings, perform the required red proofs, review and gate each final candidate, +and inspect hosted checks on its exact SHA before merging. The date target does +not waive a gate or make an unverified draft complete. Record remaining external +or implementation blockers with their next safe action if the target is missed. + +**Local platform proof restored (owner correction, 2026-09-29):** use the +available Mac mini, Kubuntu VM and Windows 11 VM before pushing a candidate, +alongside the Windows host. Recover the existing rig workflow from the project's +Claude memories and verify its current access and toolchain. Bind every result +to the candidate tree and record process exits; hosted CI confirms that proof. +PR #32 candidate `22f62ed1` passed the full Windows-host gate, Hosts and Forks, +but hosted macOS and Windows both timed out in one new paid-grant race test. +An owned temporary-directory junction reproduced that same failure locally: +the fixture compared its lexical path with an atomic writer's canonical path. +Canonicalizing the fixture makes all 22 paid tests pass under that alias. +Timeouts, production behavior and quality thresholds are unchanged. Fresh full +gates, independent review and local rig proof remain required before repushing. + +**Windows 8.3 follow-up (2026-09-29):** `74f3c2cf` passed all four local +full gates and hosted Linux/macOS, but hosted Windows still timed out in that +same fixture. The local junction proof did not cover actual short names. +`GetShortPathNameW` reproduced the runner's path form locally: JavaScript +`realpathSync` keeps 8.3 names, while `realpathSync.native` expands them as +the atomic writer's `fs.promises.realpath` does. The fixture now uses the native +method. The original short-path case exited 1 at 5,000 ms; all 22 paid tests +passed afterward under the same short path. Fresh Windows host/VM full gates +must run with actual short-name temporary paths before the next push; the Mac +and Kubuntu gates also rerun. No production or timeout change. + **Pre-PR delivery, historical (2026-09-26: trigger merged at `10522223`; first manual branch dispatch run `36276240077` succeeded on head `ac9df5a` in all seven jobs).** The owner @@ -7274,7 +7917,7 @@ trigger was verified locally with a red drill before the M52 merge. | CSS lint | `stylelint "src/**/*.css" --max-warnings=0` | M0 ✓ | | Types | `tsc --noEmit` over five projects: host, webview, unit, e2e, integration (`npm run typecheck`) | M0 ✓ | | Dead code | `knip` (not `--strict`; see knip.jsonc) | M0 ✓ | -| Cycles | `dpdm --no-warning --no-tree --exit-code circular:1 -T src/extension.ts src/host/backend/modelApiEntry.ts src/webview/main.tsx` | M0 ✓; M57: the Model API bundle's entry added, without which its files left the check (227 files analysed instead of 203) | +| Cycles | `dpdm --no-warning --no-tree --exit-code circular:1 -T src/extension.ts src/host/backend/modelApiEntry.ts src/webview/main.tsx src/runtime/main.ts` | M0 ✓; M57: the Model API bundle's entry added, without which its files left the check (227 files analysed instead of 203); PR #32: the ACP agent's entry | | Duplication | `jscpd` (config `.jscpd.json`: threshold 0 over `src` and `test`) | M0 ✓ | | Unit tests + coverage | `vitest run --coverage`; thresholds 90 % statements, 85 % branches, 90 % functions and lines (D5); Windows runs test files serially (M50) | M0 ✓ | | Integration tests | `vscode-test` (two configurations: `stable` and `minimum`, the `engines.vscode` floor) | M0 ✓ (9 passing locally since M18; CI: ubuntu xvfb + windows); M26 ✓ on 1.139.0 and 1.125.0, downloads cached in CI | @@ -7288,8 +7931,18 @@ trigger was verified locally with a red drill before the M52 merge. | PowerShell lint | `node scripts/lint-ps.mjs` (PSScriptAnalyzer over `native/windows`, `npm run lint:ps`) | M9 ✓ on Windows (exit = finding count; a reported skip on other platforms; installed on the CI Windows runner). M26: pinned to 1.25.0 (`-RequiredVersion`), the version CI installs. CI falls back to the Gallery's package endpoint, checked against the pinned SHA-512, when its search answers "No match" (2026-09-27; drill: a wrong hash stops the install). | | Accessibility | `node scripts/a11y.mjs` (`npm run test:a11y`, in `quality` after the build; in CI on Linux and Windows): axe-core over every harness scenario in the four default themes, WCAG 2.2 AA | M37 ✓ (proofs A–G, J–M); Lighthouse itself is not run (D32) | | Localization | `node scripts/check-l10n.mjs` (`npm run check:l10n`, in `quality:gates`): every table in `l10n/` against the English table, strictly; the manifest against `package.nls.json`; no `UI_TEXT` read at module load | M40 ✓ (drills in `docs/certification/m40.md`) | +| Host API record | `node scripts/check-host-api.mjs` (`npm run check:host-api`, in `quality:gates`; `--write` regenerates): `docs/ide-compatibility/host-api.md` against the source, and the portable code never reaching `vscode` | M60 ✓ (drills in `docs/certification/m60.md`) | +| Hosts | `hosts.yml` (VSCodium, code-server, Theia, the agent package and key store, Jupyter, Emacs, Neovim) and `forks.yml` (Cursor, Devin Desktop, Kiro, Positron), CI only: each job runs one `test/hosts` script | M62/M63 ✓ locally (drills H1–H5 in `m63.md`, F1–F2 in `m62.md`); the forks only on GitHub's runners | + +**Resolved 2026-09-27: the bundle budget after M48–M56 joined M60–M63.** +The joined tree's build failed its size check (`dist/extension.js` 600.5 +KiB against 600, `dist/acp.js` 874.1 KiB against 800) and no budget was +raised. Main's M57 took the Model API backend out of both: the extension is +432.5 KiB under its unchanged 600 KiB, and the agent, which loads the same +`dist/modelApi.js`, 713.2 KiB, whose budget is now 850 KiB (D6 amendments; +`docs/certification/pr32-integration.md`). -Aggregates: `quality:gates` = format:check, lint, typecheck, check:l10n, deadcode, cycles, duplication, test:unit, build, security:audit; `quality` = quality:gates + test:a11y + security:secrets + security:sast; `quality:ci` = quality:gates + test:a11y + test:integration (secrets and SAST are separate CI jobs). Integration tests run only in CI or via `npm run test:integration`. +Aggregates: `quality:gates` = format:check, lint, typecheck, check:l10n, check:host-api, deadcode, cycles, duplication, test:unit, build, security:audit; `quality` = quality:gates + test:a11y + security:secrets + security:sast; `quality:ci` = quality:gates + test:a11y + test:integration (secrets and SAST are separate CI jobs). Integration tests run only in CI or via `npm run test:integration`. The pre-commit hook runs `lint-staged` tasks serially, keeping the same lint and format checks with fewer simultaneous children. On 2026-09-25 Windows @@ -7320,6 +7973,7 @@ Every lint or scanner suppression (`eslint-disable`, `@ts-expect-error`, `nosemg | `scripts/sast.mjs` | `nosemgrep` on two `spawnSync` calls (`detect-child-process`) | The SAST gate's own launcher (M40): it runs `semgrep` or the semgrep executable found in a Python's user Scripts folder, and asks the interpreters in a fixed list (`python`, `python3`, `py`) where that folder is. Every command and argument is the script's own, passed as an argument array with no shell; nothing from a user, the model or a workspace reaches them, and the script never ships. | 2026-09-25 | | `src/host/backend/mcpProcess.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | A stdio MCP server the user configured in Muse Code's own settings file (M50, D42), started only in a trusted workspace: its command found by absolute path (D24), its arguments passed as an array. A `.cmd`/`.bat` launcher goes through `cmd.exe /d /v:off /s /c` with every part quoted and `"`, `%` and line breaks refused. Nothing the model writes reaches the command line. | 2026-09-25 | | `src/host/backend/mcpJobLaunch.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | On Windows M50 starts only its compiled C# executable in extension storage, with no arguments. The configured command, arguments and allowlisted environment are in a private encoded environment value; C# removes it and builds the server's exact environment before `CreateProcessW`. The server is assigned to its job before its first instruction. Since M56 the launcher's C# ships as `native/windows/MuseSparkMcpLauncher.cs` and the shared `MuseSparkMcpJob.cs`, is read by `jobSourceReader`, and compiles to an executable named by its source's digest (`jobBuild.ts`). | 2026-09-26 | +| `src/runtime/main.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | The ACP agent's `login` (M63, D62) runs `muse login` in the user's terminal the way the agent starts `muse serve`: the command is the CLI `MuseCodeBackendManager.resolveLaunch` found (the install layout, `PATH`, or an absolute `--muse-binary` that must exist, D1a, D4), the arguments its launcher's fixed prefix and `MUSE_LOGIN_ARGS`, passed as an array with no shell. Nothing from an editor, the model or a workspace reaches it. Found by the first local SAST run on PR #32's code (2026-09-27). | 2026-09-27 | | `test/unit/helpers/fakeMcpOrphan.mjs` | `nosemgrep` on `spawn` (`detect-child-process`) | The M50 Windows regression fixture starts only this Node with its own fixed file to test an MCP server whose child outlives it. The child self-exits after 12 seconds; no model or workspace input reaches its command line, and the fixture never ships. | 2026-09-25 | | `src/host/backend/modelApiBundle.ts` | `value is ModelApiBundle` (`isModelApiBundle`, a type predicate) | `require` of `dist/modelApi.js` returns `unknown`; the guard checks that `createModelApiHost` is a function, but not its parameter and result types, which no run-time check can see. Both bundles come from one source tree in one `npm run build` and ship in one package, this module types the factory on both sides, and `modelApiBundle.test.ts` builds the real bundle and runs a turn through it (M57). | 2026-09-27 | diff --git a/README.md b/README.md index 7cb5660c..e89abd95 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Marketplace version Marketplace installs CI - VS Code 1.125 or newer + VS Code 1.99 or newer WCAG 2.2 AA checked 15 languages MIT license @@ -171,7 +171,7 @@ harness:shots`) against a scripted session, so they match the build. 1. Install **Muse Spark Code** from the [Marketplace](https://marketplace.visualstudio.com/items?itemName=RandyNorthrup.muse-spark-code) - (VS Code 1.125 or newer), or from a `.vsix` attached to a + (VS Code 1.99 or newer), or from a `.vsix` attached to a [GitHub Release](https://github.com/RandyNorthrup/muse-spark-code/releases): ```bash @@ -255,6 +255,21 @@ signs in. Unsent draft text stays in the composer. When an installer makes a signed-in Muse Code CLI available in `auto` mode, the current Model API session ends before the next message starts a fresh CLI session. +## Other editors + +The same two backends run outside VS Code as `muse-spark-code-acp`, an +agent for editors that speak the Agent Client Protocol (Zed, JetBrains IDEs, +Neovim, Emacs and others), attached to each GitHub Release. +[docs/acp.md](docs/acp.md) covers installing it, where it keeps a Model API +key (the operating system's credential store), and the editor's settings. +VS Code forks built on VS Code 1.99 or later can install the extension +from a `.vsix`, and from Open VSX once a release is published there. +[docs/ide-compatibility/hosts.md](docs/ide-compatibility/hosts.md) records +which editors have been tried: so far VSCodium, code-server, Eclipse +Theia, Cursor, Devin Desktop (formerly Windsurf), Kiro and Positron with +the extension, and Zed, Emacs (agent-shell), Neovim (CodeCompanion) and +JupyterLab (Jupyter AI) with the agent. + ## Permission modes | Mode | Model API backend | Muse Code backend | @@ -1316,7 +1331,8 @@ stopped and the next message resumes the same session. - **The extension's own requests** (the Model API, the paid features, Muse Voice's socket) go through VS Code's network support, as every - extension's `fetch` and WebSocket do from VS Code 1.125: `http.proxy`, or + extension's `fetch` does on every VS Code this extension supports, and + its WebSocket from VS Code 1.112: `http.proxy`, or the system's proxy settings or PAC file; proxy authentication as VS Code handles it (Basic and Kerberos); `http.noProxy`; and the operating system's certificate store while `http.systemCertificates` is @@ -1326,6 +1342,15 @@ stopped and the next message resumes the same session. the root's file in `NODE_EXTRA_CA_CERTS` before VS Code starts works only with `http.systemCertificates` off: in the M56 drill the variable did not help while that setting was on (its default). +- **Muse Voice on VS Code 1.101 to 1.111**: those versions do not route an + extension's WebSocket, so Muse Voice's socket goes to Meta directly, + without VS Code's proxy, proxy authentication, `http.noProxy` or the + certificates VS Code adds. Behind a proxy or a network that inspects + HTTPS, use VS Code 1.112 or later for Muse Voice; the Model API's requests + are routed on every supported version. On 1.99 and 1.100 Muse Voice is + unavailable (their Node has no WebSocket). An editor built on VS Code may + route neither; **Muse Spark: Diagnostics** says which of the two this one + routes. - **Muse Code** reads proxy variables from its environment (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY`). The extension hands it VS Code's `http.proxy` (and `http.noProxy`) when neither its environment nor @@ -1341,13 +1366,19 @@ stopped and the next message resumes the same session. store; `SSL_CERT_FILE` or `SSL_CERT_DIR` replace that store for it entirely, so a file named there must hold every root it needs. - **Muse Spark: Diagnostics** reports which of these are set (never a - proxy's address) and known-safe source and generation fields from + proxy's address), whether this editor routes the extension's `fetch` and + WebSocket at all, and known-safe source and generation fields from Muse Code's managed configuration (`muse config status`). Unrecognized lines and failed-command output stay out of the public-issue report. ## Requirements -- VS Code 1.125.0 or newer, on Windows, macOS or Linux. +- VS Code 1.99.0 or newer, on Windows, macOS or Linux, or an editor built + on it: VSCodium 1.99.3 and 1.135, code-server 4.99.4 and Theia 1.75 were + tested (see [hosts.md](docs/ide-compatibility/hosts.md)). On 1.99 and 1.100, whose + extension host is Node 20, Muse Voice is unavailable; on 1.101 to 1.111 its + socket does not use VS Code's proxy support (see + [Proxies and certificates](#proxies-and-certificates)). - The [Muse Code CLI](https://dev.meta.ai/products/muse-code/) signed in with a Meta account (subscription), or a Meta Model API key (pay as you go). - `git` on `PATH` for `.gitignore`-aware `@` mentions, worktrees and the @@ -1569,6 +1600,11 @@ stopped and the next message resumes the same session. - **A workflow has no Cancel, Skip or Retry button** — this increment follows its progress read-only. Owner controls wait for a captured accepted-command and outcome shape from Muse Code. +- **The Muse Spark sidebar is blank in Eclipse Theia** — Theia 1.75 does not + start an extension when its webview view opens, so the view waits until + something else starts it. Press **Ctrl+Esc** or run any Muse Spark command + (**Open in New Tab**, **Show Logs**) and the sidebar fills in; it works + from then on in that window. - **The microphone says "Voice dictation failed: No microphone is available"** — Windows sees no recording device from this session (Remote Desktop hides the host's devices unless the client redirects a microphone). On macOS, @@ -1620,7 +1656,7 @@ helpers are Windows PowerShell and Swift with no dependencies. | `npm run lint` | `eslint --max-warnings=0` (type-aware), `stylelint --max-warnings=0`, and PSScriptAnalyzer 1.25.0 over `native/windows` (Windows only; a reported skip elsewhere) | | `npm run typecheck` | `tsc --noEmit` for the host, webview, unit-test, e2e-test and integration-test projects | | `npm run deadcode` | `knip`: unused files, exports, dependencies (no `--strict`; see `knip.jsonc`) | -| `npm run cycles` | `dpdm` circular-import check from the extension's, the Model API backend's and the webview's entry points | +| `npm run cycles` | `dpdm` circular-import check from the extension's, the Model API backend's, the webview's and the ACP agent's entry points | | `npm run duplication` | `jscpd` copy-paste detection (threshold 0) | | `npm run test:unit` | vitest with coverage thresholds (90 % statements/lines/functions, 85 % branches); includes `test/e2e/`, where a fake Muse Code CLI is spawned as a real child process (a compiled stub on Windows) and driven through the real backend manager | | `npm run test:e2e:live` | One real turn on the installed Muse Code CLI, opt-in with `MUSE_LIVE_E2E=1`; bills the signed-in subscription (25 to 45 model attempts measured for a reply-only turn: one for the answer, the rest for Muse Code's bundled reminder agents, which loop a varying number of times; budget 60, counted from the CLI's trace log); never in CI | @@ -1631,10 +1667,12 @@ helpers are Windows PowerShell and Swift with no dependencies. | `npm run security:sast` | `semgrep scan --config auto --error` through `scripts/sast.mjs`, which also finds a semgrep that pip put in Python's user Scripts folder when that folder is not on the shell's PATH | | `npm run security:secrets` | `gitleaks git` over the repository history | | `npm run check:l10n` | The localization gate: every table in `l10n/` has every key of the English one (`src/shared/l10n/en.ts`) with the same `{slots}`, code spans and bold markers, exactly the plural forms its language uses, and nothing left in English but the names `l10n/untranslated.json` allows; every string `package.json` shows is a `%key%` of `package.nls.json`; and nothing reads `UI_TEXT` while its module loads | -| `npm run quality:gates` | `format:check`, `lint`, `typecheck`, `check:l10n`, `deadcode`, `cycles`, `duplication`, `test:unit`, `build`, `security:audit`: what CI runs on all three platforms | +| `npm run check:host-api` | The host API gate (PLAN.md D60): checks `docs/ide-compatibility/host-api.md`, the record of every VS Code API the extension uses and where, the files that import `vscode`, the Node built-ins and what the webview needs from its host, against the source; fails when it is stale (`-- --write` regenerates it) and when the engine, the protocol, the webview or a portable host module reaches `vscode` | +| `npm run quality:gates` | `format:check`, `lint`, `typecheck`, `check:l10n`, `check:host-api`, `deadcode`, `cycles`, `duplication`, `test:unit`, `build`, `security:audit`: what CI runs on all three platforms | | `npm run quality` | `quality:gates`, then `test:a11y`, `security:secrets` and `security:sast`; **exits non-zero on any finding** | | `npm run quality:ci` | `quality:gates`, `test:a11y`, then `test:integration` (no secrets or SAST); CI itself runs these as separate steps, see Releases | | `npm run package` | `vsce package --no-dependencies` (after `vscode:prepublish` runs `npm run build`) → `.vsix`; it carries the macOS helper only if `bash native/darwin/build.sh` built it first, on a Mac | +| `npm run package:acp` | Production build, then `scripts/package-acp.mjs` → `dist/muse-spark-code-acp-.tgz`, the ACP agent's npm package (`docs/acp.md`), with its own third-party notices | | `npm run clean` | Remove `dist/` and `coverage/` | **Tests.** Unit tests (`test/unit/**`) run under vitest with `vscode` aliased @@ -1644,7 +1682,13 @@ in `test/unit/helpers/` implement the full VS Code interfaces. The e2e tests that answers the Muse Session Protocol, including approvals, questions and subagents. Integration tests (`test/integration/**`) run under mocha inside a real VS Code launched by `@vscode/test-cli` (on Linux under `xvfb-run -a`), -against `test/fixtures/workspace/`. +against `test/fixtures/workspace/`. The host checks (`test/hosts/`, CI's +Hosts workflow) run the packaged extension in VSCodium, code-server and +Eclipse Theia, and the packaged ACP agent in JupyterLab, Emacs and +Neovim, each against the fake CLI; the Forks workflow installs the VSIX +in the latest Cursor, Devin Desktop, Kiro and Positron and runs the +integration tests there. `sh test/hosts/run-.sh` runs one locally, +with the arguments its header gives. **Quality gates.** Every gate fails the build rather than printing, and each was seen to fail on a deliberate break before being trusted; the records are @@ -1661,7 +1705,7 @@ and stays English. Escape hatches (`eslint-disable`, `@ts-expect-error`, casts) need an inline reason and a row in `PLAN.md` §8. Bundle budgets: 600 KiB for the extension, 400 KiB for the Model API backend's own bundle, 50 KiB for the search worker, 900 KiB for -the webview. +the webview, and 850 KiB for the ACP agent (`dist/acp.js`). **Environment variables.** Credentials live in SecretStorage, never in files. `.env.example` documents `META_API_KEY`, which the Muse Code CLI @@ -1688,7 +1732,8 @@ test/e2e/ the fake Muse Code CLI and the tests that drive the test/integration/ @vscode/test-cli suites test/fixtures/workspace/ the workspace the integration tests open test/harness/ the webview behind a fake host, for screenshots and the accessibility gate; themes/ holds VS Code's four default themes -scripts/ esbuild build; bundle-size, bundle-split, host-globals, notices, audit, PSScriptAnalyzer, accessibility and localization gates; the pseudo-locale; theme capture, harness screenshots, image rendering; CHANGELOG notes and VS Code versions for the workflows +test/hosts/ the extension and the ACP agent in other editors (VSCodium, code-server, Theia, JupyterLab, Emacs, Neovim), one script per host +scripts/ esbuild build; bundle-size, bundle-split, host-globals, notices, audit, PSScriptAnalyzer, accessibility, localization and host API gates; the pseudo-locale; theme capture, harness screenshots, image rendering; CHANGELOG notes and VS Code versions for the workflows; the ACP agent's package docs/ PRIVACY.md, and certification/: per-milestone gate-fire records media/ icons, banner, social preview, README screenshots .github/ workflows (ci, build, release), issue and pull-request templates, audit exceptions, pinned semgrep, CODEOWNERS, Dependabot, FUNDING diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 978815e4..1693442a 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -275,6 +275,56 @@ fields, never raw configuration or failed-command output. - The "Muse Spark" output channel logs what the extension does, with keys and tokens redacted. It is not written to disk by the extension. +## The agent for other editors + +`muse-spark-code-acp` (the npm package, `docs/acp.md`) runs Muse Spark in +editors that speak the Agent Client Protocol. It sends what the editor +hands it, the same way the extension does, and nothing else: + +- **Your prompts** and what the editor attaches to them (files, excerpts, + images) go to Meta through the backend the editor started it with, as + above: the Muse Code CLI under Meta's Muse Code terms, or `api.meta.ai` + with your key. Which files and selections ride along is the editor's + choice, not the agent's. +- **The editor's MCP servers** (their commands, arguments, environments, + URLs and headers) are handed to the Muse Code CLI for the session, which + starts or calls them; the agent logs only their names. The Model API + backend runs none. +- **Muse Code's sign-in** is read as the extension reads it (above): the + structure of `auth.json` only, and `account/read` on a short-lived + `muse serve` where only the CLI can say. +- **The key** is kept by `auth set` in the operating system's credential + store under "Muse Spark Code (Unofficial)" (Windows Credential Manager, + the macOS Keychain, the Secret Service on Linux), never in a file, and + is never read from an environment variable or an argument, logged, or + passed to Muse Code. `auth clear` deletes it. On Linux without an + unlocked Secret Service the Model API backend is unavailable; there is + no plaintext fallback. +- **Model API conversations** are saved as in the extension, one folder + per workspace named by a hash of its path, under + `%LOCALAPPDATA%\Muse Spark Code` on Windows, + `~/Library/Application Support/Muse Spark Code` on macOS and + `$XDG_DATA_HOME/muse-spark-code` elsewhere. Muse Code conversations stay + in the CLI's own store. The paid features you allowed always in a folder + are kept beside them in `acp/paid-uses.json.d`: feature directories, each + folder's hash and random generation identifiers used to revoke old grants. + These records contain no prompt, file content, account or credential. Old + generations are inert; a stale process cannot restore revoked permission. + Legacy `paid-uses.json` maps are ignored and their next use asks again. +- **The network**: the agent's own requests go to `api.meta.ai` through + Node's `fetch`, and through a proxy only when its environment asks for + one (`docs/acp.md`, "Networks and proxies"); VS Code's proxy and + certificate settings do not apply to it. +- **The log** goes to stderr, which the editor shows or keeps as its agent + log; keys and tokens are redacted. +- The folder's rules, skills and memory are read only with + `--trust-workspace`; contributor-tier models are listed only with + `--allow-contributor-models`; web search and image generation only with + `--web-search` or `--image-generation`, and each use only once you allow + it in the editor's prompt, which names the price (Allow once, Allow + always in this workspace with `--trust-workspace`, or Deny). + It has no telemetry either. + ## Your choices - `museSpark.confidentialWorkspace` blocks contributor-tier models and hides diff --git a/docs/acp.md b/docs/acp.md new file mode 100644 index 00000000..21ffb9f1 --- /dev/null +++ b/docs/acp.md @@ -0,0 +1,329 @@ +# Muse Spark in other editors (ACP) + +`muse-spark-code-acp` runs Muse Spark as an agent in any editor that speaks +the [Agent Client Protocol](https://agentclientprotocol.com): Zed, the +JetBrains IDEs through AI Assistant, Xcode 27, Qt Creator, Neovim, Emacs, +Sublime Text, Devin Desktop and others. The editor shows the chat, the tool +calls, the plan and the permission prompts; the agent runs Muse Code (or +the Meta Model API) the way the VS Code panel does. It is unofficial and not +endorsed by Meta. + +The configuration below names the command and its arguments. Where each +editor keeps its agent settings is in that editor's documentation, linked +from [the compatibility plan](https://github.com/RandyNorthrup/muse-spark-code/blob/main/docs/ide-compatibility.md#32-ides-and-editors-reached-through-a-shared-acp-agent); +[hosts.md](https://github.com/RandyNorthrup/muse-spark-code/blob/main/docs/ide-compatibility/hosts.md) records which editors have been +tested, at which version, and what was found. + +## Install + +Node.js 22 or later is required. + +- From a GitHub Release, by the package's URL: + + ```sh + npm install -g https://github.com/RandyNorthrup/muse-spark-code/releases/download/v/muse-spark-code-acp-.tgz + ``` + + or download `muse-spark-code-acp-.tgz` and run + `npm install -g ./muse-spark-code-acp-.tgz`. + +- From npm, once the first release is published there: + `npm install -g muse-spark-code-acp`. + +`muse-spark-code-acp --version` confirms the install. + +On Windows, npm installs the command as a `.cmd` launcher, which some +editors cannot start. If the editor reports that it cannot find or start +`muse-spark-code-acp`, give it `node` as the command and the agent's +script as the first argument, before the others: +`node "\muse-spark-code-acp\dist\acp.js"`, where +`` is the folder `npm root -g` prints (usually +`%APPDATA%\npm\node_modules`). The same form works on every platform. + +## Choose who pays + +The agent runs on one backend, chosen when the editor starts it; it never +switches on its own. + +| Backend | Arguments | Who pays | Needs | +| ----------------------------------- | -------------------- | ---------------------------- | -------------------------------------------------------- | +| Muse Code (the default) | (none) | Your Muse Code subscription | The Muse Code CLI, signed in | +| Meta Model API (bring your own key) | `--backend modelApi` | Your Model API key, per call | A key stored with `muse-spark-code-acp auth set` (below) | + +Configure two agents in the editor if you want both. + +## Sign in + +Editors that run sign-ins in a terminal offer the right one when the agent +asks for it. Elsewhere, run it yourself once: + +- **Muse Code**: `muse-spark-code-acp login` runs Muse Code's own sign-in. + The agent tells whether Muse Code is signed in as the VS Code panel + does: from the structure of the CLI's credential file (the emptied file + `muse logout` leaves counts as signed out); `META_API_KEY` in the + agent's environment counts too, and is handed to Muse Code only: no + command, hook or program the agent itself runs sees it or any other + `*_API_KEY` variable. Where only the CLI can say (a macOS + Keychain sign-in), the agent asks it when the editor checks the sign-in + again after you sign in (ACP's `authenticate`), and otherwise assumes + the sign-in holds until a turn says it does not. +- **Model API key**: `muse-spark-code-acp auth set` asks for the key without + showing it and keeps it in the operating system's credential store: + Windows Credential Manager, the macOS Keychain, or on Linux the Secret + Service (GNOME Keyring, KWallet, KeePassXC). `auth status` says whether + one is stored; `auth clear` removes it. + +The key is never read from an environment variable, a settings file or an +argument, and never passed to Muse Code. On Linux without a running, +unlocked Secret Service the Model API backend is unavailable; there is no +plaintext fallback. On Windows, Credential Manager cannot be used from a +session opened over SSH with a key (Windows reports +`ERROR_NO_SUCH_LOGON_SESSION`): run `auth set` from a desktop session, and +expect a Model API agent started in such a session to say the credential +store cannot be used. + +## Configure the editor + +Every editor needs the same two things: the command, +`muse-spark-code-acp`, and the arguments. The configurations below were +tested with the editor versions they name; check each editor's current +documentation if the format has moved on. + +In Zed (tested with 1.20.2), a custom agent goes in `settings.json`; it +then appears under External Agents in the Agent Panel's new-thread menu: + +```json +{ + "agent_servers": { + "Muse Spark": { + "type": "custom", + "command": "muse-spark-code-acp", + "args": [], + "env": {} + } + } +} +``` + +In Emacs, [agent-shell](https://github.com/xenodium/agent-shell) takes an +agent configuration; this one was tested with agent-shell 0.79.2 and +Emacs 29.3 (add `"--backend" "modelApi"` to the arguments for the other +backend): + +```elisp +(require 'agent-shell) + +(defun muse-spark-agent-config () + (agent-shell-make-agent-config + :identifier 'muse-spark + :mode-line-name "Muse Spark" + :buffer-name "Muse Spark" + :shell-prompt "Muse> " + :shell-prompt-regexp "Muse> " + :client-maker (lambda (buffer) + (acp-make-client :command "muse-spark-code-acp" + :command-params '() + :context-buffer buffer)))) + +(defun muse-spark () + "Start a Muse Spark shell." + (interactive) + (agent-shell-start :config (muse-spark-agent-config))) +``` + +`M-x muse-spark` opens the shell; a permission prompt takes `y` to allow +once and `C-c C-c` to reject (which also stops the turn). + +In Neovim, [CodeCompanion](https://github.com/olimorris/codecompanion.nvim) +takes an ACP adapter; this one was tested with CodeCompanion v19.25.0 and +Neovim 0.11.4: + +```lua +require("codecompanion").setup({ + adapters = { + acp = { + muse_spark = function() + return require("codecompanion.adapters").extend("goose", { + name = "muse_spark", + formatted_name = "Muse Spark", + commands = { default = { "muse-spark-code-acp" } }, + }) + end, + }, + }, + interactions = { chat = { adapter = "muse_spark" } }, +}) +``` + +`:CodeCompanionChat` opens a chat; a permission prompt lists its keys +(Accept, Reject, Cancel) in the chat buffer. + +In JupyterLab 4, [Jupyter AI](https://github.com/jupyterlab/jupyter-ai) 3 +(`pip install jupyter-ai`; tested with 3.2.0 and JupyterLab 4.6.3) runs +ACP agents as chat personas. Save this as +`.jupyter/personas/muse_spark_persona.py` in the folder JupyterLab serves +(the file name must contain `persona`), with any square SVG beside it as +`muse_spark.svg`, then open a new chat and pick Muse Spark: + +```python +import os + +from jupyter_ai_acp_client.base_acp_persona import BaseAcpPersona +from jupyter_ai_persona_manager import PersonaDefaults + + +class MuseSparkAcpPersona(BaseAcpPersona): + def __init__(self, *args, **kwargs): + super().__init__(*args, executable=["muse-spark-code-acp"], **kwargs) + + @property + def defaults(self) -> PersonaDefaults: + return PersonaDefaults( + name="Muse Spark", + description="Muse Spark Code (Unofficial) through its ACP agent.", + avatar_path=os.path.join(os.path.dirname(__file__), "muse_spark.svg"), + system_prompt="unused", + ) +``` + +Jupyter AI offers the agent its notebook tools as an MCP server, which +the agent passes to Muse Code (below). + +Other editors take the same command and arguments in their own agent or +ACP settings (JetBrains AI Assistant, Xcode's Intelligence settings, Qt +Creator's ACP Client, sublime-acp, Devin Desktop's custom agents). + +## Options + +| Argument | Effect | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `--backend museCode\|modelApi` | Which backend, and so who pays (default `museCode`) | +| `--trust-workspace` | Load the folder's rules, skills and memory, as Muse Code's own flag does. Without it the folder is treated as untrusted | +| `--muse-binary ` | The Muse Code CLI to run; by default the agent looks where the VS Code extension looks | +| `--shell-sandbox auto\|muse\|off` | Muse Code's shell sandbox, as the extension's `museSpark.shellSandbox` setting | +| `--allow-dangerously-skip-permissions` | Offer the Bypass permissions mode | +| `--allow-contributor-models` | List contributor-tier models, whose content Meta may train on; they are hidden otherwise | +| `--web-search` | Offer paid web search (Model API backend only); each prompt asks in the editor first, naming the price | +| `--image-generation` | Offer paid image generation (Model API backend only); each image asks in the editor first, naming the price | +| `--verbose` | Log every detail to stderr (the editor's agent log) | + +## What the editor sees + +- **Modes**: Manual, Edit automatically, Plan, Auto (and Bypass permissions + with its flag), as in the panel. A session loaded or resumed runs in the + mode the editor is told, not the one it last ran in. +- **Settings**: the model and the reasoning effort. A session loaded or + resumed runs on the model and effort the editor is shown; one last run + on a model the agent does not list moves to the default. A session the + agent cannot set up this way is let go, and the editor's request fails. +- **Commands**: the session's skills, run as `/name arguments`. +- **Permission prompts**: the backend's own choices (allow once, allow for + the session, reject). A prompt the editor cancels, or answers with a + choice it was not offered, is rejected; nothing runs by default. +- **Questions** the agent asks: a form where the editor has forms, + otherwise the question as text, answered in your next message. A form + that comes back with an answer that is not one of the options offered + (text is taken only where the question has no options), or with more + or fewer than the question allows (at least one where it sets no + bound, as in the panel), is declined. +- **Sessions**: listed, loaded with their history, resumed and closed. + Closing a session (or loading it again) ends its running prompt as + cancelled and stops that turn, and an answer you give it afterwards + decides nothing. +- **Prompts**: text, files as @mentions, attached excerpts, and PNG, JPEG, + GIF and WebP images up to 10 MB. +- **MCP servers** the editor offers (Zed's context servers, Jupyter AI's + notebook tools): passed to Muse Code for the session, over stdio or + HTTP, and optional, so one that fails to start does not stop the + session. SSE servers are not taken; the Model API backend runs none. + +## Paid features + +Web search ($2.50 per 1,000 searches) and image generation ($0.01 per +image) cost money on top of tokens and are billed to your Model API key. +They are off unless the editor starts the agent with `--web-search` or +`--image-generation` (with `--backend modelApi`). Then every use asks +first in the editor's permission prompt, as the VS Code panel's popup +does, naming what is about to be billed and its price: each prompt that +may search the web (Meta runs the searches inside the reply, so the +question comes once per prompt, not per search) and each image. The +answers are **Allow once**, **Allow always in this workspace** and +**Deny**; Deny sends the prompt without web search, or makes no image. +A cancelled prompt, an answer the prompt did not offer, or an editor +that cannot ask counts as Deny. + +**Allow always in this workspace** is offered only when the agent runs +with `--trust-workspace`. It is kept for that folder in the agent's data +folder (`paid-uses.json.d` under `%LOCALAPPDATA%\Muse Spark Code\acp`, +`~/Library/Application Support/Muse Spark Code/acp` or +`$XDG_DATA_HOME/muse-spark-code/acp`), holding feature directories, workspace +hashes and random revocation identifiers. Each feature has a current generation; +each workspace grant names that generation. Concurrent processes cannot restore +a revoked grant or overwrite a newer explicit grant. Legacy `paid-uses.json` +maps are ignored, so their next use asks again. Storage that cannot safely +publish a generation keeps the explicit use as Allow once and asks next time. +The grant lapses in every folder when the agent starts without that feature's +flag, so turning the flag on again asks again. Every paid row names its +price, and the agent log counts each billed use. Subagents, scheduled +prompts and Muse Voice are not offered: the agent has no flag for them +(Muse Voice needs the VS Code panel's microphone). + +## Networks and proxies + +The agent runs outside VS Code, so VS Code's `http.proxy`, +`http.noProxy`, proxy authentication, `http.systemCertificates` and PAC +files do not reach it, and neither does anything the extension's +`museSpark.environmentVariables` sets. Both backends see only the +environment the editor starts the agent with: the editor's own, plus any +`env` in the agent's configuration (Zed's `agent_servers` entry, for +example). Keep proxy credentials out of settings files you share. + +**The Model API backend** (the agent's own requests: the conversation, web +search, images) uses Node's built-in `fetch`, which by default **ignores +proxy variables**: with only `HTTPS_PROXY` set it connects to Meta +directly. To send it through a proxy, set `NODE_USE_ENV_PROXY=1` beside the +proxy variables (Node 22.21 or later on Node 22, any Node 24; checked +against a local proxy with Node 22.0.0, 22.20.0, 22.21.0, 22.23.3, 24.0.0, +24.5.0 and 24.20.0). Node then reads: + +- `HTTPS_PROXY` (or `https_proxy`) for Meta's HTTPS address, falling back + to `HTTP_PROXY`; a `user:password@` in the proxy's address is sent to + the proxy as its credentials (`Proxy-Authorization`); +- `NO_PROXY` for hosts to reach directly; +- not `ALL_PROXY`, and no system proxy settings or PAC file. + +`NODE_OPTIONS=--use-env-proxy` does the same from Node 22.21 and 24.5. +Without the switch, or on an older Node, there is no way to route the +agent's own requests through a proxy. + +Certificates: Node trusts its own bundled roots. A network that inspects +HTTPS needs its root named in `NODE_EXTRA_CA_CERTS` (a PEM file, read when +the agent starts; checked with Node 22.0.0 to 24.20.0), or +`NODE_OPTIONS=--use-system-ca` to trust the operating system's store +(accepted from Node 22.15; not exercised here, since that needs a root +installed in the store). + +**Muse Code** (`muse serve`, started by the agent) inherits the same +environment and reads the proxy variables itself, as it does under VS Code +([the extension's README](https://github.com/RandyNorthrup/muse-spark-code/blob/main/README.md#proxies-and-certificates)): +`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` and `NO_PROXY`, with loopback +added to `NO_PROXY` whenever a proxy is set. It trusts the operating +system's certificate store, which `SSL_CERT_FILE` or `SSL_CERT_DIR` +replace entirely, and has its own `endpoint_transport.proxy` setting. It +needs no `NODE_USE_ENV_PROXY`. + +The agent says so rather than guessing: when `HTTPS_PROXY` or +`HTTP_PROXY` (either case) is set for the Model API backend and Node's +switch is off, or this Node does not have it, the agent's log says at +start that the requests will go to Meta directly and what to set. A Model +API request that never reaches Meta is reported with Node's own detail and +advice in the agent's terms: the proxy variables and `NODE_USE_ENV_PROXY`, +or `NODE_EXTRA_CA_CERTS` and `--use-system-ca` for a certificate it does +not trust. + +## Not yet + +- The Model API backend reads and writes files itself, so it does not see + unsaved changes in the editor; save before asking it to edit a file you + have open. +- MCP servers on the Model API backend. diff --git a/docs/certification/README.md b/docs/certification/README.md index c09b959e..16d8cd03 100644 --- a/docs/certification/README.md +++ b/docs/certification/README.md @@ -73,3 +73,7 @@ The PNGs beside the records are that day's harness renders. - [M57](m57.md): the Model API backend out of the activation bundle into `dist/modelApi.js`, the identity audit and the bundle-split gate (PLAN.md D6) - [M58](m58.md): a popup before every paid use: Allow once, Allow always in this workspace, or Deny (PLAN.md D48) - [Sign-in detection](sign-in-detection.md): the CLI's sign-in read from its credential file's structure and confirmed by the CLI, sign-out through `account/logout`, and every way a browser sign-in ends (PLAN.md D26 amendment) +- [M60](m60.md): the host API record and the `vscode` boundary, with M61's host bridge and portable controller (PLAN.md D60) +- [M62a, M62b](m62.md): the VS Code floor at 1.99, from an API and Node audit, tested in VSCodium and code-server; Eclipse Theia 1.75 (PLAN.md M62, A8) +- [M63a–M63c](m63.md): the ACP agent for other editors, the Model API key in the OS credential store, and the agent's package; Zed, Emacs with agent-shell, Neovim with CodeCompanion, JupyterLab with Jupyter AI; the editors' MCP servers; the host checks in CI (PLAN.md D61, D62) +- [PR #32 joined with M57 and M58](pr32-integration.md): the ACP agent loads `dist/modelApi.js`, each paid use asks in the editor, the agent's budget, networks and proxies, the key store on the owner's Windows 11 VM and Mac mini (PLAN.md D6, D62, Q66) diff --git a/docs/certification/m60.md b/docs/certification/m60.md new file mode 100644 index 00000000..faa9fbe6 --- /dev/null +++ b/docs/certification/m60.md @@ -0,0 +1,159 @@ +# M60 certification — the host API record and the `vscode` boundary, with M61's first steps (PLAN.md M60, M61, D60) + +Recorded 2026-09-26. + +The owner handed over a plan for taking Muse Spark Code beyond VS Code +(`docs/ide-compatibility.md`, PLAN.md D60) to be built beside M45–M56. Its +first backlog items are to put the scope into PLAN.md, to inventory what +the extension asks of its host, and to cut the incidental VS Code types +out of the code other hosts will reuse. That is this record: D60, Q60–Q64 +and M60–M66 in PLAN.md; the gate; and M61's first two steps. + +## The record + +`npm run check:host-api` (`scripts/check-host-api.mjs`, in +`quality:gates`) renders `docs/ide-compatibility/host-api.md` from the +source and fails when the file differs. On this commit it holds: + +- **The manifest and build**: `engines.vscode ^1.125.0`, `engines.node + +> =22`, `main`only (no`browser`entry),`extensionKind: workspace`, + virtual workspaces unsupported, untrusted workspaces limited, no API + proposals, one activation event, seven contribution points; host bundles + built for `node22`, the webview for `chrome128`. + +- **198 VS Code APIs** used at run time, each with its files. They are + found with TypeScript's checker, not by text: a function, variable, + class, enum or member declared in `@types/vscode` and used outside a + type; the members of a VS Code interface the code implements (the view + provider, the panel serializer, the content provider, options objects); + and, since M61 hands VS Code objects to portable code by shape, the + members of such an object where it is handed over (`LogOutputChannel.*` + to `createLogger`, `SecretStorage.*` to the credential store). A field + of an inline options type names its function and parameter + (`window.createOutputChannel(options.log)`). +- **11 files import `vscode`**: `src/extension.ts` (143 APIs) and ten host + adapters (views, popups, paid features, CLI and worktree features, the + mention picker, the dictation host). +- **13 Node built-ins** the host imports, by file count. +- **The webview's host**: `acquireVsCodeApi`, now only in + `src/webview/hostBridge.ts`, and the 57 `--vscode-*` theme variables + `styles.css` reads: the list a JCEF or WebView2 host has to map (M61 + step 3). + +Whatever the record says, the portable code never reaches `vscode` +through its imports, type-only ones included: everything under +`src/core`, `src/shared` and `src/webview`, and eight host modules (the +conversation controller, both backend managers, the tool harness, the +auth service, the credential and session stores, the `ide` server). The +gate follows every relative import, `import type` and `import()` types +included, and prints the chain when one leads to `vscode`. + +## What the first run found, and M61's first two steps + +The first run failed on six of the eight host modules: + +- all six reached `vscode` through `src/host/logger.ts`, whose + `createLogger` took a `vscode.LogOutputChannel` for the four methods it + calls. It now takes the channel by shape (`Logger`, which the channel + satisfies), and the gate records the four channel methods where + `extension.ts` hands the channel over; +- the conversation controller also reached it through `ChatSurface`, which + extended `vscode.Disposable`, and through `DictationSetup`, declared in + the dictation host beside its `vscode` import. `ChatSurface` and + `ConversationMessage` moved to `src/host/views/chatSurface.ts` with a + `dispose(): void` of their own; `DictationSetup` moved to + `src/core/voice/dictation.ts`, beside the handle and listener types it + is made of. + +The webview's host bridge (M61 step 1): `src/webview/hostBridge.ts` +defines what the React app needs from any host (`post`, `savedState`, +`saveState`, and `messages`, where the host's messages arrive as `message` +events) and VS Code's implementation over `acquireVsCodeApi()`, called +once. `main.tsx` goes through it, and `listenToHost` takes any message +source rather than a `Window`. The harness's fake host still stubs +`acquireVsCodeApi`, so every harness scenario runs through the bridge. + +No behaviour changed: the same calls reach VS Code in the same order. + +## Tests + +- `test/unit/hostBridge.test.ts`: the API acquired once, posting, the saved + state read and saved through it, the host's messages taken from the + window given and no longer after the listener is removed. +- The existing suites for the store, the webview setup, the logger, the + surface registry, the commands and the conversation controller pass + with only their import paths changed. + +## Drills + +Each rule broken on purpose, the gate (or test) run, the file restored; +the scripts were `scratchpad/drills.sh` and `scratchpad/drills2.sh`. + +| Drill | Break | Result | +| ----- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| A | `vscode.window.setStatusBarMessage` added to `src/host/popups.ts` | exit 1: the record's diff adds `window.setStatusBarMessage`, the API count 198 → 199, popups.ts 2 → 3 | +| B | a new host file importing `vscode` | exit 1: "The VS Code adapter: 12 files", the new file's row, `version` gains it | +| C | `import type * as vscode` in `src/core/redact.ts` | exit 1: `src/core/redact.ts -> vscode`, and the chains through `logger.ts` for five portable host modules | +| C′ | the same, run with `--write` | exit 1: regenerating the record does not excuse the boundary | +| D | `createLogger(channel: vscode.LogOutputChannel)` restored | exit 1: six portable host modules, each `… -> src/host/logger.ts -> vscode` | +| E | `import type * as vscode` in `src/webview/errorReport.ts` | exit 1: `errorReport.ts`, and `App.tsx`, `main.tsx`, `store.ts` through it | +| F | `var(--vscode-drill-foreground)` in `styles.css` | exit 1: theme variables 57 → 58 | +| G | `acquireVsCodeApi()` called in `errorReport.ts` | exit 1: the webview's host table gains the file | +| H | the `Clipboard.writeText` row deleted from the record by hand | exit 1: the diff puts it back | +| I | `ideMcpServer.ts` renamed away while `extension.ts` still imports it | exit 1: `src/extension.ts: cannot resolve "./host/ide/ideMcpServer"` | +| I′ | a portable host module listed that does not exist | exit 1: "listed as portable but not found" | +| J | `append?(value: string)` added to `Logger`, the shape the log channel is handed to | exit 1: `OutputChannel.append` enters the record at `extension.ts`: a member used through a shape is still recorded | +| K | `vsCodeHostBridge().saveState` stops calling `setState` | `hostBridge.test.ts` fails (1 of 2) | + +A first version of drill J added `show?()`, which the record already +lists (`OutputChannel.show`, called directly), so it passed; `append` was +the right break. + +## Gates + +Run on this change in the cloud container, 2026-09-26, step by step so +that one failure does not hide the rest (`scratchpad/gate-*.log`): + +| Gate | Result | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `format:check`, `lint`, `typecheck` | exit 0 (five TypeScript projects) | +| `check:l10n` | exit 0 (no text added) | +| `check:host-api` | exit 0: 198 VS Code APIs, 11 files importing `vscode`, 13 Node built-ins, 57 theme variables | +| `deadcode`, `cycles`, `duplication` | exit 0 (0 clones) | +| `test:unit` as root | exit 1: 1,483 passed, 1 failed, `fsAtomic.test.ts` › "refuses a read-only file at once", which fails as root on `main` too (below) | +| `test:unit` as `nobody`, clean `PATH` | exit 0: 1,484 passed, 7 skipped; coverage 96.27 % statements, 91.32 % branches, 95.76 % functions, 96.27 % lines; `hostBridge.ts` 100 % | +| `build` | exit 0: 352.0, 40.8 and 682.2 KiB against 600, 50 and 900; no `navigator`; notices current (75 packages) | +| `security:audit` | exit 0: 0 advisories | +| `test:a11y` | exit 0: 252 pages (63 scenarios × 4 themes), 0 violations; every scenario mounts through the new bridge (the container's Chromium 1194, `--no-sandbox` as root) | +| `security:secrets` | exit 0: no leaks in 50 commits; the pre-commit hook's staged scan clean too (gitleaks v8.29.1, built into the session's scratch folder from its Go module, with the owner's go-ahead) | +| `security:sast` | not run: semgrep is not installed in the container | +| `test:integration` | not run: the container's network policy blocks VS Code's download servers | + +The unit suite needs an unprivileged user here: as root, `chmod 0o444` +does not stop a write, so the read-only drill of `fsAtomic.test.ts` +fails (the baseline run on `main` before this change, 01:21, failed the +same way); and as `nobody` with root's `PATH`, which lists folders under +`/root` that user cannot read, spawning a missing interpreter reports +`EACCES` rather than `ENOENT` (`toolIo.test.ts`). With `PATH` limited to +`/opt/node22/bin:/usr/local/bin:/usr/bin:/bin` the whole suite passes. +CI runs semgrep and the integration tests on the pull request. + +## After M48–M56 (2026-09-27) + +With main's M48–M56 merged, the record was regenerated. It has the same +198 VS Code APIs, 13 files importing `vscode` (M49's +`src/host/memoryFeatures.ts` and `src/host/quickPick.ts` added) and 16 Node +built-ins (`node:net`, `node:vm` added). The portable boundary holds with +M48–M56's host modules in it. The floor checks and M56's network premise +at older VS Code are in `m62.md`, "The floor after M48–M56". + +## Left for later + +- M61 steps 3–6 (theme tokens, the editor-services contract, the Node + runtime entry, capability detection): steps 3 and 4 wait for M56 to + merge, since they move the stylesheet and the controller that M45–M56 + are changing. +- M62–M66 wait on Q60–Q64: Open VSX publishing, the ACP SDK, which + editors may be installed for probes, where the key lives outside + VS Code, and the first hosts. diff --git a/docs/certification/m62.md b/docs/certification/m62.md new file mode 100644 index 00000000..0d4b8272 --- /dev/null +++ b/docs/certification/m62.md @@ -0,0 +1,437 @@ +# M62a certification — the VS Code floor at 1.99 (PLAN.md M62, A8) + +Recorded 2026-09-26. + +Editors built on VS Code refuse an extension whose `engines.vscode` is +newer than their own VS Code. The floor was `^1.125.0`, chosen at 0.1.1 +because the tree typechecked there, not from an audit. The owner's plan +(`docs/ide-compatibility.md` §3.1) keeps it unless an audit and real-host +tests justify a change. This record is that audit and those tests. The +floor is now `^1.99.0`. + +## The audit + +**The VS Code API.** The host, unit and integration projects were +typechecked against every published `@types/vscode` from 1.85 to 1.120: +25 versions, with each one swapped into `node_modules` in turn +(`scratchpad/floor-check.sh`). + +- The host: 0 errors at every version. It uses no VS Code API newer than + 1.85. +- The unit tests: 4 errors from 1.96 to 1.107 and 1 before 1.96, all in + `FakeWebviewPanel`, whose `iconPath` named `vscode.IconPath` (added in + 1.96) with 1.108's shape. It is now typed from the interface it fakes, + `NonNullable`, and holds at every + version. +- The integration tests: 0 errors at every version. + +A first attempt put the old typings in a `typeRoots` folder; every +version read 0 errors because `import 'vscode'` still resolved to +`node_modules/@types/vscode`, as `tsc --listFiles` showed. The swap +replaced it. + +**Node.** An extension runs on the Node inside the editor. VS Code's own +pins, read from its repository at each tag (`remote/.npmrc` for the +server, `.npmrc`'s Electron target for the desktop): + +| VS Code | Electron | Node (server) | +| ------- | -------- | ------------- | +| 1.99.0 | 34.3.2 | 20.18.3 | +| 1.100.0 | 34.5.1 | 20.19.0 | +| 1.101.0 | 35.5.1 | 22.15.1 | +| 1.103.0 | 37.2.3 | 22.17.0 | +| 1.107.0 | 39.2.3 | 22.21.1 | +| 1.120.0 | 39.8.8 | 22.22.1 | +| 1.125.0 | 42.3.0 | 24.15.0 | + +The host was compiled against `@types/node` 20.19.43 with the ES2023 +library (`scratchpad/tsconfig.node20.json`). One error: +`Promise.withResolvers` in `src/host/mention/mentionQuickPick.ts`, an +ES2024 API that Node 20 lacks (`node -e` on Node 20.18.3: `withResolvers: +undefined`). It is replaced with a plain `new Promise`. The only other +Node 22 feature the host touches is the global `WebSocket` (Node 20.18.3: +`undefined`). Muse Voice already checks for it and says it is +unavailable (`dictationHost.test.ts` covers that message). + +**What changed.** + +- `engines.vscode` is `^1.99.0` and `@types/vscode` 1.99.0, so every + typecheck now runs at the floor. +- The host project's library is ES2023, so a Node 22 built-in is a + compile error (drill Q). +- The host bundles (extension, search worker, integration tests) target + `node20.18`. The ACP agent keeps `node22`, since it runs on the user's + own Node. +- The webview's `chrome128` target stands: 1.99's Electron 34 is Chromium 132. + +**Why 1.99 and not lower.** 1.99 is the first release on Node 20.18 and +Chromium 132. The releases before it run Node 20.9 to 20.16 and Chromium +122 to 130 (Electron 29 to 33), which neither the host nor the webview was +checked against. Going lower waits for a named editor that needs it. + +## Real hosts + +No model was called. The conversation ran against the e2e suite's fake +Muse Code CLI, with a fake credential file. + +- **code-server 4.99.4** (its release tarball, which bundles "Code + 1.99.3" and Node 20.18.3; the npm install failed in its nested + `npm install` with ENOTEMPTY). It ran with its user data, extensions and + configuration in the scratch folder, `auth: none`, on 127.0.0.1, and was + driven by Playwright over the container's Chromium 1194 + (`scratchpad/pw/drive.cjs`, `converse.cjs`). + - The VSIX installed. + - The log read `Activating Muse Spark 0.8.0 (VS Code 1.99.3, Node +20.18.3, linux)`, then `Activated in 8 ms`, with no warning or error. + - With no CLI, the panel showed "Muse Code is not installed", the + searched paths and the Model API key route. + - With `museSpark.museBinaryPath` set to the fake CLI: + - "hello from 1.99" was answered "echo: hello from 1.99". + - "tool: echo floor-check" raised the approval card (Allow once, + Reject). Allow once ran it, and the row read "Decided: approved + (user)". + - The page's failed requests were code-server's own: `vsda`, which it + does not ship; the Open VSX gallery, blocked here; and an aborted + webview bootstrap frame. + - Screenshots: `scratchpad/cs-1.99-view.png`, `cs-1.99-reply.png`, + `cs-1.99-approval.png`, `cs-1.99-approved.png`. +- **The old floor refused (drill T)**: the same VSIX with its engine set + back to `^1.125.0` (in `package.json` and `extension.vsixmanifest`): + `Unable to install extension 'randynorthrup.muse-spark-code' as it is +not compatible with VS Code '1.99.3'.` +- **VSCodium 1.99.3** (`VSCodium-linux-x64-1.99.32846`, Electron 34; the + extension's log: `Activating Muse Spark 0.8.0 (VS Code 1.99.3, Node +20.18.3, linux)`): the integration tests through + `@vscode/test-cli` with `useInstallation.fromPath`, under `xvfb-run`, + with `--no-sandbox` as root: **9 passing**. They cover activation, the + commands, the settings and their defaults, the panel in a new tab, the + keybinding commands, machine-scoped settings, `AGENTS.md`, New + Conversation and the walkthrough. +- **VSCodium 1.135** (`1.135.06055`, the latest): the same 9 passing. + +VS Code's own downloads stay blocked here. CI's `minimum` integration run +reads the floor from `package.json` (`scripts/lib/vscode-engine.mjs`) and +now downloads 1.99.0; it runs on the next pull request. + +## Drills + +| Drill | Break | Result | +| ----- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Q | `Promise.withResolvers` back in `mentionQuickPick.ts` | `tsc` exit 2: "Property 'withResolvers' does not exist on type 'PromiseConstructor'" | +| R | `vscode.lm.registerMcpServerDefinitionProvider` (VS Code 1.101) in `activate` | `tsc` exit 2: "Property 'registerMcpServerDefinitionProvider' does not exist on type 'typeof lm'" | +| S | the build targets changed without regenerating `host-api.md` | `check:host-api` exit 1: the record's diff shows `engines.vscode` and the new `host_node`/`agent_node` rows | +| T | the VSIX's engine set back to `^1.125.0`, installed in code-server 4.99.4 | refused: "not compatible with VS Code '1.99.3'" | + +## Gates + +Run on this change in the cloud container, 2026-09-26 +(`scratchpad/quality3.log`, `unit-nobody3.log`, `gate3-*.log`): + +| Gate | Result | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run quality` as root | exit 1 at `test:unit`: 1,555 passed, 1 failed, `fsAtomic.test.ts` › "refuses a read-only file at once", the root-only failure M60 recorded; every gate before it passed | +| `typecheck` | exit 0: five projects against `@types/vscode` 1.99.0, the host on ES2023 | +| `check:l10n`, `check:host-api` | exit 0 (the record regenerated: `^1.99.0`, `host_node` `node20.18`, `agent_node` `node22`) | +| `deadcode`, `cycles`, `duplication` | exit 0 (0 clones) | +| `test:unit` as `nobody`, clean `PATH` | exit 0: 1,556 passed, 7 skipped; coverage 96.23 % statements, 91.18 % branches, 95.66 % functions, 96.23 % lines | +| `build` | exit 0: 354.5, 43.2, 684.6 and 718.6 KiB against 600, 50, 900 and 800; no `navigator`; notices current (75 packages) | +| `security:audit` | exit 0: 0 advisories | +| `test:a11y` | exit 0: 252 pages, 0 violations | +| `security:secrets` | exit 0: no leaks in 53 commits | +| `security:sast` | not run: semgrep.dev, where `--config auto` gets its rules, is refused by the container's proxy (M63's record) | +| `test:integration` | not run as configured (VS Code's downloads are blocked); the same suite ran in VSCodium 1.99.3 and 1.135 above | + +## M62b, first host: Eclipse Theia 1.75 + +Recorded 2026-09-26, same day. No model was called. + +**Setup.** A browser Theia app built from npm with `@theia/cli` 1.75.0 +(published 2026-08-27; 1.76.0 was two days old, inside the project's +seven-day rule). Its packages were core, editor, filesystem, markers, +messages, monaco, navigator, output, preferences, process, terminal, +workspace, plugin-ext and plugin-ext-vscode. + +- `theia build --mode development`: 0 errors. +- The VSIX (floor `^1.99.0`) unpacked into `plugins/` and loaded with + `--plugins=local-dir:plugins`. +- `THEIA_CONFIG_DIR` and the fake Muse Code CLI's settings and credential + were kept in the scratch folder. +- Theia claims VS Code API 1.134.0 (`DEFAULT_SUPPORTED_API_VERSION` in + `@theia/application-package`), so the old and new floors both load. +- It serves webviews from `.webview.`, so it was opened as + `localhost` (Chromium resolves `*.localhost`); under a bare 127.0.0.1 + the webviews do not load at all. + +**Results** (`scratchpad/pw/theia-final.cjs`, `theia-tab.cjs`; screenshots +`scratchpad/theia-*.png`): + +- The plugin deployed ("Deploy batch of 1 accepted plugins"). Once + started, the log read `Activating Muse Spark 0.8.0 (VS Code 1.134.0, +Node 22.22.2, linux)`, then `Sign-in state: signedIn on the museCode +backend`. +- **Open in New Tab**: the panel rendered (welcome, tips, composer, Manual + mode). "hello from theia" was answered "echo: hello from theia", and + "tool: echo theia-check" raised Allow once / Reject; Allow once ran it + ("Decided: approved (user)"). +- **The sidebar opened first stays blank.** Instrumenting a copy of the + bundle showed `activate` never ran. In + `@theia/plugin-ext/lib/main/browser/view/plugin-view-registry.js`, + `registerWidgetPartEvents` fires `onDidExpandView`, which is what + triggers `onView:`, only when the view's widget has no children. A + webview view is created with its webview child, so `onView:` never + fires for it. That holds whether the event is implicit (VS Code 1.74+) + or declared, as a test with `onView:museSpark.chatView` added to the + manifest showed. + - The webview waits for its provider, and Theia's pending-revival path + resolves it as soon as the extension starts some other way. After + **Ctrl+Esc**, the sidebar filled in, and a conversation and an + approval ran in it. + - Starting the extension at startup (`onStartupFinished`) would hide + this, but it would also start the extension, and read SecretStorage, + in every VS Code window. README's Troubleshooting gives the shortcut + instead, and the fix belongs in Theia. +- **SecretStorage**: without a D-Bus session, Theia fell back to an + in-memory store ("OS level credential store could not be accessed"). A + Model API key would not survive a restart here; on a desktop with a + keyring it would. + +## M62b, the forks in CI: Cursor, Devin Desktop, Kiro, Positron + +Recorded 2026-09-26, same day. None of these can be downloaded here: the +update feeds of Cursor (`api2.cursor.sh`), Devin Desktop +(`windsurf-stable.codeium.com`) and Kiro (`prod.download.desktop.kiro.dev`) +and Posit's CDN refuse the connection. So the check runs on GitHub's +runners (`.github/workflows/forks.yml`), weekly, by hand and on pull +requests that change it. + +**How each fork is found.** `test/hosts/fork-release.mjs latest ` +reads the fork's own update feed, the one its nixpkgs update script +(`code-cursor`, `kiro`, `positron-bin`) or Homebrew cask (`devin-desktop`) +reads, and prints the version and the Linux x64 package: Cursor's +AppImage, Devin Desktop's and Kiro's tar archives, Positron's `.deb` (its +version from the GitHub release). Windsurf left nixpkgs, and Homebrew +renamed its cask `windsurf` to `devin-desktop`. + +**What the job does** (`test/hosts/run-fork.sh`): unpacks the package +without installing it (`--appimage-extract`, `dpkg-deb -x`, `tar`), finds +`resources/app/product.json`, prints the product's name, its own version +and the VS Code version it is built on (also in the job summary), +installs the VSIX with the fork's CLI (`bin/`) and checks +it is listed, then runs the integration tests in the fork +(`installed.vscode-test.mjs`, shared with the VSCodium job). A fork whose +VS Code base is under the floor refuses the install, so the job fails +there. + +**Local runs**, with VSCodium 1.99.32846's three Linux packages standing +in through `FORK_URL` (the AppImage, the `.deb` and the tarball, the +formats the four forks ship): each unpacked, printed "VSCodium (VS Code +1.99.32846)", installed and listed `randynorthrup.muse-spark-code@0.8.0`, +and passed the integration tests (9 passing). + +**Drills:** + +| Drill | Break | Result | +| ----- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| F1 | the VSIX with the old `^1.125.0` floor, in VSCodium 1.99 | exit 1: "Unable to install extension 'randynorthrup.muse-spark-code' as it is not compatible with VSCodium" | +| F2 | a package that is not an editor (the agent's npm tarball) | exit 1: "FAIL drill-f2: no resources/app/product.json in the package" | + +**First CI run** (Forks run 36222382949 on this branch's merge of main, +2026-09-26): all four jobs passed, each installing and listing +`randynorthrup.muse-spark-code@0.8.0` and passing the 9 integration tests. + +| Fork | Package | Printed | +| ------------- | ----------------------------------------- | --------------------------------------- | +| Cursor | `Cursor-3.22.7-x86_64.AppImage` | Cursor 3.22.7 (VS Code 1.128.0) | +| Devin Desktop | `Devin-linux-x64-3.10.35.tar.gz` | Devin 3.10.35 (VS Code 1.126.0) | +| Kiro | `kiro-ide-1.1.70-stable-linux-x64.tar.gz` | Kiro (VS Code 1.1.70): wrong, see below | +| Positron | `Positron-2026.09.1-2-x64.deb` | Positron 2026.09.1 (VS Code 1.130.0) | + +- Kiro numbers its product itself, so `describe` took Kiro's 1.1.70 for the + VS Code base. It now takes only a VS Code release number (1.NN.N) from + `vscodeVersion`, the product's or the manifest's version, and otherwise + says the base is not named and lists `product.json`'s version fields. + The second run (36223213890) listed them: Kiro keeps its base as + `vsCodeVersion`, 1.131.0, which `describe` now reads too. All four + passed again. +- Nothing in the logs came from the extension but Devin Desktop's check for + a newer version on its gallery (HTTP 429). Each fork's own services + (sign-in, sandbox preflight, telemetry) logged errors without a network + account; none affected the tests. + +## The floor after M48–M56 + +Recorded 2026-09-27. Main's M48–M56 (PRs #35–#44, `origin/main` +`890e371`) were merged into this branch. The floor has to hold for their +code too, and M56 rested on something about VS Code that had been read +only at 1.125. No model was called and no credential was used. + +**The VS Code API.** Every project typechecks at `@types/vscode` 1.99.0. +The host API record was regenerated (`npm run check:host-api -- --write`): +still 198 APIs, since M48–M56 use none the host did not already use. Two +more files import `vscode`, `src/host/memoryFeatures.ts` (M49's Memory +view) and `src/host/quickPick.ts`, making 13. `node:net` and `node:vm` join +the Node built-ins, making 16. The portable code still never reaches +`vscode`. + +**Node 20.18.** The ES2023 host library found `Promise.withResolvers` +twice in M55's `src/host/auth/deviceSignIn.ts`. Both are now +`settleOnAbort`, a plain promise that also removes its abort listener. With +`@types/node` 20.19.43 swapped into `node_modules` (and 22.20.4 put back +afterwards), the host and integration projects have 0 errors. The lines +M48–M56 added use nothing newer than ES2023 (`toSorted`, `toReversed`, +`findLast`, `findLastIndex`, `Object.hasOwn`, `AbortSignal.timeout`, which +all exist in Node 20.18 and Chromium 132). + +**The ACP agent.** Its backends take the inputs M48–M56 added to the +managers: + +- Muse Code's own network sandbox default (`default`: no + `--sandbox-network`). +- The in-memory prompt cache (`in_memory`). +- M49's memory store, in the data home `muse serve` sees, so a trusted + folder gets `read_memory`, `add_memory` and `edit_memory`, as the panel + does. +- No subagents. They are paid (M48), and the agent's only paid features + are its two flags, so `confirmSubagentTask` always declines. +- The shell job's C#, read from the package: `scripts/package-acp.mjs` now + ships `native/windows/MuseSparkJob.cs` and `MuseSparkMcpJob.cs`. + +M55 dropped `MUSE_LOGIN_ARGS` because the panel signs in through MSP's +device code. The agent's `login` still runs `muse login` in the editor's +terminal, so the constant is back, with a note. + +### M56's premise on older VS Code + +M56 (PLAN.md D43) relies on VS Code's extension host replacing an +extension's global `fetch` and `WebSocket` with proxy-aware ones. That was +read at 1.125.0. The same file, +[`src/vs/workbench/api/node/proxyResolver.ts`](https://github.com/microsoft/vscode/blob/1.99.0/src/vs/workbench/api/node/proxyResolver.ts), +was read at each tag below, with the `@vscode/proxy-agent` range the tag's +`package.json` pins. The tag for 1.110 is spelled `1.110`; `1.110.0` does +not exist. + +| VS Code tag | proxy-agent | Node (server) | `fetch` patched | `WebSocket` patched | +| ----------- | ----------- | ------------- | --------------------------------------- | ----------------------------------------------------- | +| 1.99.0 | ^0.32.0 | 20.18.3 | yes (`patchGlobalFetch`, lines 99, 117) | no (and Node 20 has no WebSocket) | +| 1.100.0 | ^0.32.0 | 20.19.0 | yes | no (no WebSocket either) | +| 1.101.0 | ^0.32.0 | 22.15.1 | yes | no | +| 1.105.0 | ^0.35.0 | 22.19.0 | yes | no | +| 1.110 | ^0.38.0 | 22.22.0 | yes | no | +| 1.111.0 | ^0.38.0 | 22.22.0 | yes | no | +| 1.112.0 | ^0.39.1 | 22.22.0 | yes | **yes** (`patchGlobalWebSocket`, lines 126, 209, 215) | +| 1.115.0 | ^0.40.0 | 22.22.1 | yes | yes | +| 1.120.0 | ^0.41.0 | 22.22.1 | yes | yes | +| 1.125.0 | ^0.42.0 | 24.15.0 | yes | yes | + +The Node column is each tag's `remote/.npmrc`. + +- **(a) `fetch`** is patched at every tag from 1.99.0, the floor. VS Code + 1.99.0 calls `proxyAgent.createFetchPatch` while + `http.fetchAdditionalSupport` is on. Its defaults, in + [`request.ts`](https://github.com/microsoft/vscode/blob/1.99.0/src/vs/platform/request/common/request.ts), + are all on: `fetchAdditionalSupport` true, `proxySupport` `override`, + `systemCertificates` true, `electronFetch` false. proxy-agent 0.32.0's + `createFetchPatch` resolves the proxy per URL and adds the system roots + to the request's dispatcher + ([`src/index.ts` at v0.32.0](https://github.com/microsoft/vscode-proxy-agent/blob/v0.32.0/src/index.ts), + line 565). So the Model API client, which calls `liveFetch`, is routed at + every supported version. +- **(b) `WebSocket`** is patched from **1.112.0**. That tag is the first to + call `patchGlobalWebSocket` + ([1.112.0](https://github.com/microsoft/vscode/blob/1.112.0/src/vs/workbench/api/node/proxyResolver.ts); + [1.111.0](https://github.com/microsoft/vscode/blob/1.111.0/src/vs/workbench/api/node/proxyResolver.ts) + has none). It is also the first to register + `http.webSocketAdditionalSupport` + ([request.ts at 1.112.0](https://github.com/microsoft/vscode/blob/1.112.0/src/vs/platform/request/common/request.ts)). + It pins proxy-agent ^0.39.1. The proxy-agent + [CHANGELOG](https://github.com/microsoft/vscode-proxy-agent/blob/main/CHANGELOG.md) + says 0.39.0 added `createWebSocketPatch` and 0.39.1 made it respect + `doResolveProxy` and `addCerts`. + +**What breaks below 1.112.** On VS Code 1.101 to 1.111, Node 22 has a +global WebSocket that VS Code does not replace. Muse Voice's socket +(`openWebSocket`, M35) therefore connects to Meta directly: + +- no `http.proxy`, system proxy or PAC file; +- no proxy authentication; +- no `http.noProxy`; +- by default, no certificates VS Code adds. + +The one TLS patch those versions apply to every connection +(`createTlsPatch`, which `createPatchedModules` merges into `tls`) adds +system roots only under `http.experimental.systemCertificatesV2`, off by +default. Behind a proxy that is the only way out, the socket fails to +connect. Behind a network that inspects HTTPS, it fails TLS. On a network +that allows direct connections, it bypasses the proxy's policy. On 1.99 +and 1.100 there is no WebSocket, and Muse Voice already says it is +unavailable. The Model API's requests are not affected at any version. + +**What changed.** + +- Diagnostics no longer infers routing from the settings alone. + `hostRouting` (`src/host/networkPosture.ts`) checks whether the extension + host set the global VS Code's `proxyResolver.ts` leaves beside each patch: + `__vscodeOriginalFetch` (every tag above) and `__vscodeOriginalWebSocket` + (1.112.0 on). +- The line reads, for example, "fetch yes, WebSocket no (this editor does + not route it; VS Code does from 1.112)", or "WebSocket none in this + extension host" on Node 20. +- An editor built on VS Code that does not run VS Code's extension host + (Theia) sets neither global, so it is not claimed to route either one. +- A future VS Code that renamed a marker would be reported as not routing + it: an under-claim, never a false one. +- The README's Proxies and certificates and Requirements sections, D43 (an + amendment) and CHANGELOG say the same. +- Nothing refuses Muse Voice on those versions, since it works where + direct connections are allowed. A proxy client of the extension's own + stays out (D43). + +### Drills + +Each break was made by `scratchpad/drill.ps1`, the command run, and the +file restored. + +| Drill | Break | Result | +| ----- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| N-R1 | the agent's Model API manager given `memory: undefined` | `acpModelApi.test.ts` exit 1: "expected '[{"type":"function","name":"read_file…' to contain 'read_memory'" | +| N-R2 | `hostRouting` claims a route without the marker | `networkPosture.test.ts` exit 1: 2 of 9 failed (1.101's WebSocket and the unpatched host read `routed`) | +| N-R3 | the report prints the setting for a global the editor does not route | `supportReport.test.ts` exit 1: 1 of 5 failed | +| N-R4 | `Promise.withResolvers` back in `deviceSignIn.ts` | `tsc -p tsconfig.json` exit 2: "Property 'withResolvers' does not exist on type 'PromiseConstructor'" | + +### Gates + +Run on the joined tree on Windows 11 (Node 24.20.0), 2026-09-27, each +alone (`scratchpad/gates2/*.log`). `npm run quality` and the accessibility +gate were left to the owner's full run. + +| Gate | Result | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm ci` | exit 0: 900 packages from the PR's lockfile (main changed no dependency), 0 vulnerabilities | +| `format:check`, `lint`, `typecheck` | exit 0 (PSScriptAnalyzer findings: 0; five projects at `@types/vscode` 1.99.0, the host on ES2023) | +| `check:l10n`, `check:host-api` | exit 0: 14 tables, 93 manifest strings; 198 VS Code APIs, 13 files importing `vscode`, 16 Node built-ins, 57 theme variables | +| `deadcode`, `cycles`, `duplication` | exit 0 (0 clones) | +| `test:unit` | exit 0: 2,591 passed, 5 skipped (180 files); coverage 94.36 % statements, 89.69 % branches, 95.96 % functions, 94.33 % lines | +| `build` | **exit 1** at the size check: `extension.js` 600.5 KiB against 600, `acp.js` 874.1 KiB against 800 (PLAN.md §7, open for the owner); host globals and notices pass when run on their own | +| the agent's package | `node scripts/package-acp.mjs`: 22 files, the two `.cs` files among them; installed into a scratch prefix, the stdio suite against it passed (4 of 4), as it did in the repository | + +`acpRuntime.test.ts` failed on this machine at first, and would fail on the +PR's own head here: its "CLI missing" case searched this process's `PATH`, +`LOCALAPPDATA` and home, where Muse Code is installed. The test now points +all four at an empty folder while it runs. + +Not run here: `keystore.sh`, which would write into this computer's +Credential Manager; and the editors of `hosts.yml` and `forks.yml`, which +are not installed on this machine. (`keystore.sh` ran on the owner's +Windows 11 VM and Mac mini on 2026-09-27: `m63.md`, "The key store on the +owner's rigs".) + +## Left for later (M62b) + +- The forks on macOS and Windows, and Trae (no Linux build), on the + owner's machines. +- Firebase Studio, Che and Codespaces: browser hosts with accounts, for + the owner. +- Theia: report the `onView:` gap for webview views upstream + (eclipse-theia/theia), with the code location above. +- The Open VSX listing after the next tag, and an install from it in + VSCodium. diff --git a/docs/certification/m63.md b/docs/certification/m63.md new file mode 100644 index 00000000..0abc30b5 --- /dev/null +++ b/docs/certification/m63.md @@ -0,0 +1,535 @@ +# M63a certification — the ACP agent, its key store and its package (PLAN.md M63, D61, D62) + +Recorded 2026-09-26. + +The owner asked for Muse Spark Code in as many editors as possible, the +most used first, with the ACP SDK added and a safe place for the Model +API key outside VS Code. Most of the popular editors outside the VS Code +family host agents over the Agent Client Protocol (Zed, JetBrains AI +Assistant, Xcode 27, Qt Creator, Neovim, Emacs, Sublime Text, Devin +Desktop). So the first deliverable is one agent that runs the panel's two +backends for all of them. This record covers M63a: the agent, the key +store, the package and the release steps. No editor has run the agent +yet; that is M63b, tracked in `docs/ide-compatibility/hosts.md`. + +## What was built + +| Piece | What it does | Checked by | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `src/acp/translate.ts` | The engine's events as ACP session updates: text and reasoning chunks, tool calls announced once and completed with their output (clipped) or a diff, plans, the session's name and context use, the backend's notices; permission options from the backend's choices and the decision taken from the client's answer (picked, else the backend's deny-once, else any deny); prompt blocks as the engine's parts (text, images up to 10 MB, links inside the folder as @mentions, embedded text as attached context) | `acpTranslate.test.ts` (18) | +| `src/acp/questions.ts` | The agent's questions as an elicitation form (one required field each; a choice, several, or free text) and the answers read back | `acpTranslate.test.ts` | +| `src/acp/agent.ts` | `initialize` (a terminal sign-in only for a client that runs one), `authenticate`, `session/new`, `load`, `resume`, `list`, `close`, `set_mode`, `set_config_option` (model, effort), `prompt`, `cancel`; every update sent before the prompt's answer; approvals through `session/request_permission`, questions through forms or as text; skills as commands; the session held before let go when it is loaded again | `acpAgent.test.ts` (24), in process against the SDK's own client | +| `src/runtime/` | The process: strict arguments, the stderr log (redacted), the display language, the data folder, the two backend managers with what VS Code gives them in the extension, `auth set/status/clear` and `login`, the hidden key prompt, the stdin adapter | `acpRuntime.test.ts` (24), `acpModelApi.test.ts` (2), `acpStdio.e2e.test.ts` (4) | +| `src/runtime/keyStore.ts` | D61: the key in the OS credential store through `@napi-rs/keyring`, service `Muse Spark Code (Unofficial)`, account `museSpark.modelApiKey`; Linux pinned to the Secret Service | `acpRuntime.test.ts`; the live check below | +| `scripts/build.mjs`, `check-bundle-size.mjs` | `dist/acp.js` (CommonJS, Node 22, the keyring binding left external), its metafile, and an 800 KiB budget | `npm run build`; drill F | +| `scripts/third-party-notices.mjs --acp` | The agent's own notices, from its two bundles, with the same allow-list | drill G | +| `scripts/package-acp.mjs` (`npm run package:acp`) | `dist/muse-spark-code-acp-.tgz`: the bundles, the tables, the licence, the notices, `docs/acp.md` as its README and a manifest with the keyring binding as its one dependency | CI's entry check (drill H); installed with `npm install -g` here | +| `.github/workflows/build.yml`, `release.yml` | The package built and checked in CI and attached to each release; Open VSX (`OVSX_PAT`) and npm (`NPM_TOKEN`) publishing in the `marketplace` environment, each skipped while its token is missing | first proof on the next `v*` tag | +| `src/core/agent/approvalRules.ts` | Edit automatically's rule for a plain file write, moved out of the conversation controller so the agent applies the same one | the controller's suites unchanged; `acpAgent.test.ts` | +| `scripts/check-host-api.mjs` | `src/acp` and `src/runtime` added to the portable roots; ambient `@types/vscode` names (`Thenable`) refused there | drills D, E | +| `docs/acp.md`, `docs/ide-compatibility/hosts.md`, README | The agent's guide; the tracker for every editor, all Planned but VS Code | read through | + +The tests over stdio build `src/runtime/main.ts` with esbuild into a +temporary package and drive it with the SDK's client against the fake +Muse Code CLI. They cover the version, the help and an unknown argument; +a streamed reply with one allowed and one denied tool call; a cancel and +the session list afterwards; and `auth_required` for a signed-out Muse +Code and for the Model API with no stored key. The Model API backend +cannot run over stdio in a test, because the process reads the key only +from the OS store. So `acpModelApi.test.ts` runs the agent in process on +the same runtime backend, with the real tool harness and session store on +disk, against the fake Model API. It covers a write allowed and made, a +write cancelled and not made, the session listed and loaded with its +history, the key sent only as the bearer token, and no paid tool offered. + +## Live checks in the container + +No model was called: the key was a made-up one, and the one `serve` run +stopped at `auth_required`. + +- **The key store against a real Secret Service** (GNOME Keyring 46.1 in a + private D-Bus session, unlocked with a throwaway password; + `scratchpad/keyring-live.sh`). The installed package ran `auth status`, + `auth set` (a fake key piped in), `status`, then `clear` and `status`. + `secret-tool search` showed one item, label + `keyring:museSpark.modelApiKey@Muse Spark Code (Unofficial)`, with + attributes `service` and `username`. + - **Finding**: the first run reported a stored key before `set` and + after `clear`. `AsyncEntry.getPassword()` resolves `null` for a + missing entry, although its typings say `undefined` + (`scratchpad/probe.cjs`: `absent -> null object true`). The store now + turns `null` into `undefined`, the fake keyring in the tests answers + `null` as the binding does, and a test reads a missing entry through + `auth status` (drill I). The second run printed, in order: no key + stored (exit 1), stored (0), present (0), removed (0), no key stored + (1). +- **No Secret Service**: with no D-Bus session, `auth status` and + `auth set` both exit 1. They print that the credential store cannot be + used, the binding's reason, and that Linux needs a running, unlocked + Secret Service. Nothing is written anywhere else. +- **`serve --backend modelApi` with no key**, driven by hand over stdio: + `initialize` offers only the terminal method `model-api-key` with + `args: ["auth","set"]`. `session/new` answers error `-32000`, + "Authentication required: No Meta Model API key is stored; store one + and try again." +- **The package**: `npm install -g ./dist/muse-spark-code-acp-0.8.0.tgz` + into a temporary prefix fetched `@napi-rs/keyring` 2.1.0 with its + `linux-x64-gnu` binary. `muse-spark-code-acp --version` printed 0.8.0, + and `LANG=fr_FR.UTF-8 … --help` printed the French help. The tarball + holds 20 files (431 KB). + +## What the new tests found + +- A call the user denies on the Model API backend ends with the status + `rejected`, which the translator did not count as failed, so the + editor would have shown a denied write as completed. Found by + `acpModelApi.test.ts`; `rejected` is now failed (drill J). +- Loading or resuming a session the agent already held replaced it + without letting the old one go: its listener stayed and the backend + kept its hold. The old one is now disposed first (drill K). + +## Drills + +Each rule broken on purpose, the check run, the file restored +(`scratchpad/m63-drills.sh`, log `scratchpad/m63-drills.log`). + +| Drill | Break | Result | +| ----- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A | `decidedChoice` answers `choices[0]` (allow once) when the client picks nothing it offered | exit 1: 3 tests fail (the decision table; a cancelled, unknown or failed answer; an approval with no deny) | +| B | `session/new` stops checking the backend is signed in | exit 1: `acpAgent.test.ts` and the stdio test, "promise resolved … instead of rejecting" | +| C | the terminal sign-in offered to every client (`return true`) | exit 1: the initialize test. A first version, `auth?.terminal !== false`, passed: the SDK fills `terminal: false` when a client sends no capability, so that break changed nothing | +| D | `export type Drill = Thenable` in `src/runtime/locale.ts` | exit 1: `Thenable is a VS Code type, which portable code does not use` | +| E | `import type * as vscode` in `src/acp/questions.ts` | exit 1: `questions.ts -> vscode`, and the chains from `agent.ts`, `backends.ts` and `main.ts` | +| F | the agent's budget 800 → 700 KiB | exit 1: `OVER dist/acp.js: 718.6 KiB (budget 700 KiB)` | +| G | the ACP SDK's licence set to `SSPL-1.0` in `node_modules` | exit 1: `@agentclientprotocol/sdk: licence "SSPL-1.0" is not on the allow-list` | +| H | `package-acp.mjs` leaves out `l10n/`; CI's entry check run on the tarball | exit 1: `::error::package/l10n/ui.de.json is missing from the agent's package` | +| I | the store passes the binding's `null` through | exit 1: 2 tests ("expected null to be undefined"; `auth status` exit 0 instead of 1) | +| J | `rejected` removed from the failed statuses | exit 1: the translator test and the Model API test's cancelled write | +| K | `register` no longer disposes the session it replaces | exit 1: "expected vi.fn() to be called 1 times, but got 0 times" | +| L | `parseArgs` not strict | exit 1: the unit test ("expected 'serve' to be 'invalid'") and the stdio test (exit 0 instead of 1) | +| M | `login` runs the CLI without `login` | exit 1: the login test | +| N | a cancelled stdin stream keeps its `data` listener | exit 1: "expected 1 to be +0" | +| O | paid features on in the agent | exit 1: "not to contain 'web_search'" | +| P | the Model API key read from an environment variable instead of the store | exit 1: both Model API tests (the fake API refuses the call) | + +## Gates + +Run on this change in the cloud container, 2026-09-26 +(`scratchpad/quality.log`, `unit-nobody.log`, `gate-*.log`): + +| Gate | Result | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run quality` as root | exit 1 at `test:unit`: 1,555 passed, 1 failed, `fsAtomic.test.ts` › "refuses a read-only file at once", the root-only failure M60 recorded; every gate before it passed | +| `format:check`, `lint`, `typecheck` | exit 0 (five TypeScript projects) | +| `check:l10n` | exit 0: 14 tables, 0 problems (the agent's 21 new keys in every language) | +| `check:host-api` | exit 0: 198 VS Code APIs, 11 files importing `vscode`, 14 Node built-ins, 57 theme variables | +| `deadcode`, `cycles`, `duplication` | exit 0 (three entry points for `dpdm`; 0 clones) | +| `test:unit` as `nobody`, clean `PATH` | exit 0: 1,556 passed, 7 skipped; coverage 96.23 % statements, 91.18 % branches, 95.65 % functions, 96.23 % lines. `src/acp` 96.16 % statements; `src/runtime` 94.5 % | +| `build` | exit 0: 354.5, 43.2, 684.6 and 718.6 KiB against 600, 50, 900 and 800; no `navigator`; the extension's notices unchanged (75 packages) | +| `security:audit` | exit 0: 0 advisories | +| `test:a11y` | exit 0: 252 pages, 0 violations (Chromium 1194 with `--no-sandbox` as root, through `CHROME_PATH`) | +| `security:secrets` | exit 0: no leaks in 51 commits; the staged scan runs in the pre-commit hook | +| `security:sast` | not run: semgrep 1.177.0 (CI's pin) installed, but `--config auto` fetches its rules from semgrep.dev, which the container's proxy refuses (403) | +| `test:integration` | not run: the container's network policy blocks VS Code's download servers | + +## M63b, first client: Emacs + +Recorded 2026-09-26, same day. No model was called: the agent (repackaged +from this tree and installed with `npm install -g` into a scratch prefix) +ran on the Muse Code backend against the e2e suite's fake CLI, through +`--muse-binary` and a fake credential file. + +**Setup.** + +- Emacs 29.3 from Ubuntu's archive (`emacs-nox`). +- acp.el 0.15.2, shell-maker 0.97.3 and agent-shell 0.79.2, 49 files + fetched one by one from raw.githubusercontent.com by following their + `require` lines. GitHub's source archives, MELPA and jsDelivr are + refused by the container's proxy. +- `HOME` pointed at a scratch folder, so no Emacs configuration was + touched. + +**acp.el alone** (`scratchpad/elisp/muse-acp-check.el`, `emacs --batch`). +acp.el is the protocol library agent-shell is built on, and an +implementation independent of the SDK the tests use: + +- `initialize`: `muse-spark-code-acp 0.8.0`, auth method + `muse-code-login`. acp.el advertises no terminal auth, so the method + came as the agent type with instructions. +- `session/new`: `session-1`, modes manual, acceptEdits, plan and auto; + config options model and effort. +- Prompt 1: `end_turn`, reply "echo: hello from emacs". +- Prompt 2 (`tool: echo from-emacs`): one permission request with + `allow_once` and `reject_once`, answered `allow_once`. The updates were + `tool_call/in_progress`, then `tool_call_update/completed`, and the + prompt ended `end_turn`. +- `session/list`: `session-1`. + +**agent-shell** (`scratchpad/elisp/muse-agent-shell-check.el`, batch; the +configuration is the one in `docs/acp.md`, with acp.el's public +`acp-make-client`). The shell buffer showed: + +- "Ready", and collapsed sections for the agent's stderr notices, its + capabilities, the Model and Effort options (six effort levels), the + model and the four modes with their descriptions. +- `Muse> hello from agent-shell`, answered "echo: hello from + agent-shell". +- `tool: echo from-agent-shell` as a "Tool Permission" block with + `[ Allow once (y) ] [ Reject (C-c C-c) ]`. `y` allowed it: + "✓ Command PowerShell: echo from-agent-shell", its output, "ran: echo + from-agent-shell". +- `tool: echo rejected-by-user`, rejected with `C-c C-c`: "✗ Command + PowerShell: echo rejected-by-user", "rejected by the user", "skipped: + …", then "Cancelled". agent-shell rejects by cancelling the turn, so the + permission is answered `cancelled`, which the agent turns into a + rejection (D62) and then a `cancelled` stop. + +Nothing in the agent changed for this client. + +## M63b, second client: Neovim + +Recorded 2026-09-26, same day, the same agent and fake CLI. + +**Setup.** + +- Neovim 0.11.4 from its GitHub release (neovim.io is refused here). +- plenary.nvim and CodeCompanion, cloned with `git clone` (which the + proxy allows), CodeCompanion checked out at v19.25.0 (2026-09-16; `main` + was two days old). +- `HOME` and Neovim's data, state and cache folders pointed at the scratch + folder; `-u` gave the configuration, so no user configuration was read + or written. +- The adapter is the one in `docs/acp.md`, extending CodeCompanion's + Goose preset (a plain ACP adapter), with `--muse-binary` pointing at the + fake CLI. + +**Run** (`scratchpad/nvim/check.lua`, `nvim --headless`). A chat opened +with `require("codecompanion").chat()`, each message added and submitted +through the chat's own API. CodeCompanion's approval prompt was wrapped +only to log it, and its choice was made by pressing that choice's key in +the chat buffer (`nvim_feedkeys`). + +- "hello from neovim": "## CodeCompanion (Muse Spark)", then "echo: hello + from neovim". +- "tool: echo from-neovim": "Approval Required / Execute: PowerShell: echo + from-neovim" with `g2` Accept, `g3` Reject and `g4` Cancel. `g2`: "You + selected: Accept", then "ran: echo from-neovim". +- "tool: echo rejected-in-neovim": the same prompt; `g3`: "You selected: + Reject", then "skipped: echo rejected-in-neovim". + +CodeCompanion advertises `fs.readTextFile` and `writeTextFile`; the agent +does not use them yet (M63c). Nothing in the agent changed for this +client. + +## M63b, third client: Zed + +Recorded 2026-09-26, same day, the same agent and fake CLI. + +**Setup.** + +- Zed 1.20.2 (2026-09-17; 1.21.0 was three days old), the Linux build + from its GitHub release. zed.dev is refused here. +- Zed refuses to run as root, so it ran as `nobody` on Xvfb, with Mesa's + software Vulkan (lavapipe, from Ubuntu's archive), from a run folder + under `/tmp` (the session's scratch folder is root-only), deleted + afterwards. `HOME`, `XDG_CONFIG_HOME` and `XDG_DATA_HOME` pointed into + that folder. +- `settings.json` held the agent as Zed's documentation now gives it + (`"type": "custom"`, `command`, `args` with `--muse-binary`, `env`). +- Driven with xdotool, screenshots with ImageMagick + (`scratchpad/zed-shots/`). + +**Run.** + +- After Zed's "Unrecognized Project" prompt (Trust and Continue), "agent: + toggle focus" opened the Agent Panel. The new-thread menu listed "Muse + Spark" under External Agents. +- The thread "New Muse Spark Thread" showed the agent's model ("Muse Spark + 1.3") and effort ("High") selectors. +- "hello from zed": "echo: hello from zed". +- "tool: echo from-zed": a "Run Command / PowerShell: echo from-zed" card + with "Allow once (Alt-Shift-A)" and "Reject (Alt-Shift-X)" while the + thread waited. Allow once: "ran: echo from-zed". +- "tool: echo rejected-in-zed", Reject: the card was marked declined, then + "skipped: echo rejected-in-zed". +- Zed's log (`scratchpad/zed-shots/Zed.log`) carried the agent's stderr, + eight lines per start, at Zed's WARN level ("agent stderr: [info] …"). + Its errors were the container's: no network for Zed's own services and + the ACP Registry, and no readable CA bundle for `nobody`. + +The guide's Zed example lacked `"type": "custom"`, which Zed's +documentation now requires; `docs/acp.md` is corrected. + +## M63b, fourth client: JupyterLab (Jupyter AI) + +Recorded 2026-09-26, same day, the same agent and fake CLI. + +**Setup.** + +- A Python venv in the scratch folder with `jupyter-ai` 3.2.0 + (2026-09-03), which brings `jupyter-ai-acp-client` 0.3.0 and the Python + ACP SDK 0.11.1. +- JupyterLab pinned to 4.6.3 and `jupyter-ai-persona-manager` to 0.2.0: + 4.6.4 and 0.2.1 were five and three days old. +- `HOME` and the Jupyter config, data and runtime folders pointed into the + scratch folder, and the server listened on 127.0.0.1 without a token. +- The persona is the file in `docs/acp.md`, read from + `.jupyter/personas/` in the served folder, with the agent's path and + `--muse-binary` taken from the environment for this run. + - Found: the persona manager loads only files whose name contains + `persona`; the first name, `muse_spark.py`, was skipped without a word + ("Jupyter AI has no AI personas available"). The guide says so. + +**Run** (Playwright over the container's Chromium; `scratchpad/pw/jlab3.cjs`, +screenshots `scratchpad/jlab-*.png`): + +- A new chat from the launcher opened the agent ("Initialized new ACP + client session for 'MuseSparkAcpPersona' with ID 'session-1'"). +- The composer showed the persona (with its avatar) and the agent's model + (Muse Spark 1.3), mode (Manual), effort (High) and context (0%). +- "hello from jupyter": the reply echoed Jupyter AI's own prefix, a + "System note" telling the model to edit notebooks only through Jupyter's + notebook MCP tools, before "hello from jupyter". +- "tool: echo from-jupyter": Allow once and Reject buttons. Allow once: + "✓ PowerShell: echo from-jupyter — Allow once", then "ran: echo + from-jupyter". +- "tool: echo rejected-in-jupyter", Reject: "✗ … — Reject", then + "skipped: echo rejected-in-jupyter". + +**Finding.** Jupyter AI passes its MCP servers in `session/new`: stdio +ones always, HTTP ones (its notebook tools) only to an agent whose +`initialize` answer advertises `mcpCapabilities.http`. The agent +advertises neither and passes none on to the backend, so the note above +points the model at tools it does not have. This is M63c's MCP item. + +## M63c, first item: the editor's MCP servers + +Recorded 2026-09-26, same day, after JupyterLab showed the gap. + +**What changed.** + +- `SessionMcpServer` in the engine is HTTP or stdio (`command`, `args`, + `env`). Muse Code's host maps each to MSP's `streamableHttp` or `stdio` + transport, `optional` as the IDE server already was, so a server that + fails to start never blocks the session. The VS Code extension's own + `ide` server is unchanged. +- The agent's `initialize` advertises `mcpCapabilities` `{ http: true, +sse: false }` on the Muse Code backend (`http: false` on the Model API + backend). `session/new`, `session/load` and `session/resume` pass the + editor's servers (`mcpServersFrom`) when the host granted `sessionMcp`; + otherwise a warning names them and says why. +- SSE servers, the unstable `acp` transport and a repeated name are left + out with a warning. A stdio server is known by its `command`, since some + clients send `type: "stdio"` although the schema has none. +- Only names are logged: an HTTP header or a child environment can hold a + token. + +**Tests.** `acpTranslate.test.ts` (the mapping, the left-out kinds, an +explicit `stdio` type), `acpAgent.test.ts` (the capability, forwarding on +new and resume, names only in the log, nothing without the grant or on +the Model API backend), `MuseCodeHost.test.ts` (a stdio server in +`session/resume`'s config). + +**JupyterLab again**, with the repackaged agent: the server log read +`[info] MCP servers from the editor: Jupyter MCP Server`, since Jupyter AI +now sends its HTTP notebook server to an agent that advertises HTTP. The +conversation and both permission paths ran as before. + +**Drills** (`scratchpad/m63c-drills.sh`, log `m63c-drills.log`): + +| Drill | Break | Result | +| ----- | ------------------------------------------------------ | --------------------------------------------------- | +| S1 | servers passed without checking the `sessionMcp` grant | exit 1: "passes no MCP servers without the grant …" | +| S2 | a stdio server sent to Muse Code as `streamableHttp` | exit 1: the host's resume test ("to match object") | +| S3 | the forwarded servers logged whole | exit 1: "not to contain 'token secret'" | +| S4 | `mcpCapabilities.http` false on Muse Code | exit 1: two agent tests | +| S5 | an SSE server forwarded as HTTP | exit 1: the mapping test | + +**Gates** on this change: `npm run quality` as root stops at the root-only +`fsAtomic` test (1,559 passed, 1 failed) with every gate before it green; +as `nobody`, 1,560 passed, 7 skipped, coverage 96.26 % statements and +91.19 % branches; `build`, `security:audit`, `security:secrets` and +`test:a11y` exit 0. + +## M63c, second item: paid features in the agent + +Recorded 2026-09-26, same day. No model was called; the Model API was the +fake one. + +**What changed.** (Superseded on 2026-09-27, when M58 joined: each paid +use now asks in the editor with Allow once, Allow always in this workspace +or Deny, and the first prompt's "Turn on" question below is gone; +`pr32-integration.md`, PLAN.md D62's amendment.) + +- `--web-search` and `--image-generation` (`cliArgs.ts`), refused with + `--backend museCode`: "--web-search needs --backend modelApi: paid + features bill a Model API key." Muse Voice has no flag: the agent has + no microphone. +- `src/acp/paid.ts` holds the flagged features and the answers for the + process. At the first prompt after launch, each flagged feature gets a + tool call row ("Turn on Web search?", with what it does and its price, + from new `acpPaidConfirm*` strings in all 15 languages) and a + `session/request_permission` on it with "Turn on" (`allow_always`) and + "Keep off" (`reject_always`). Only "Turn on" turns it on; "Keep off", a + cancelled question or a client that cannot answer leaves it off, and it + is not asked again. Two prompts at once share one question. +- The runtime's Model API backend reads `isPaidFeatureOn` from it and + logs every billed use with a running total ("Paid use of webSearch: 1, + 1 since the agent started"). +- A paid row's and a paid approval's title names the price: "… (Billed to + your Model API key: $0.01 per image)". Images are still asked for one by + one, in every mode (M34's rule). +- A `session/cancel` while the price is asked ends the prompt `cancelled` + without starting a turn. + +**Tests.** `acpAgent.test.ts` (nothing asked without a flag; the question, +its options and the price; decline; a client that cannot answer; cancel +during the question; the price on a paid row and approval), +`acpModelApi.test.ts` (in process against the fake Model API: web search +offered to the model only after "Turn on", a search tallied; neither paid +tool offered after "Keep off"), `acpRuntime.test.ts` (the flags, and the +refusal on Muse Code), `acpPaid.test.ts` (one question for two prompts, +the tally). + +**Drills** (`scratchpad/m63c-paid-drills.py`, log `m63c-paid-drills.log`; +each file restored from its backup after the run): + +| Drill | Break | Result | +| ----- | ---------------------------------------------------- | --------------------------------------------------------------------- | +| P1 | `isOn` true for any flagged feature, accepted or not | exit 1: 5 tests, among them "keeps a declined feature off" | +| P2 | any selected option taken as acceptance | exit 1: the decline tests in the agent and against the fake Model API | +| P3 | no price in a paid title | exit 1: "names the price on a paid approval and a paid row" | +| P4 | a paid flag accepted with the Muse Code backend | exit 1: "refuses a paid feature on the Muse Code backend" | +| P5 | a cancel during the price question ignored | exit 1: "ends the prompt cancelled, without a turn" | +| P6 | the question not awaited before the turn | exit 1: web search missing from the first request; the cancel test | + +## The host checks in CI (M62, M63) + +Recorded 2026-09-26, same day. Every host run by hand above is now a +script in `test/hosts/`, and `.github/workflows/hosts.yml` runs each one +against the fake Muse Code CLI (`test/e2e/fake-muse/`): no model is +called and no real key is used. It runs on pull requests and pushes to +main that touch the product or the checks, every Monday (the hosts' own +new releases) and by hand. + +| Job | Script | What it checks | +| --------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| agent package (3 OS) | `acpStdio.e2e.test.ts` with `MUSE_ACP_PACKAGE_DIR` | the stdio suite against the package as `npm install --global` put it: its own keyring binding, no repository module on `NODE_PATH` | +| | `keystore.sh` | no key, `auth set` from a pipe, `status`, `clear`, no key: Secret Service (gnome-keyring), a job-owned Keychain, Credential Manager | +| VSCodium 1.99.32846, latest | `run-vscodium.sh` | the integration tests (`installed.vscode-test.mjs`); `latest` read from VSCodium's own version feed | +| code-server 4.99.4, latest | `run-code-server.sh`, `code-server.mjs` | the VSIX installed; in Chrome, a reply, a command allowed and one rejected in the view | +| Eclipse Theia | `run-theia.sh`, `theia.mjs` | Theia 1.75.0 built from `test/hosts/theia/package.json`; the same conversation in the sidebar (Ctrl+Esc) and in a tab (the command) | +| ACP client jupyter | `run-jupyter.sh`, `jupyter.mjs` | JupyterLab 4.6.3 with Jupyter AI 3.2.0 and the persona file; the conversation in the chat, and the notebook's MCP server in the agent log | +| ACP client emacs | `run-emacs.sh`, `acp-check.el`, `agent-shell-check.el` | acp.el v0.15.1 and agent-shell v0.77.4 in batch Emacs: 10 checks: acp.el's handshake, modes, reply, allow once and session list; agent-shell's reply, allow with `y`, reject with `C-c C-c` | +| ACP client neovim | `run-neovim.sh`, `init.lua`, `check.lua` | Neovim v0.11.4 and CodeCompanion v19.25.0 headless: a reply, a command allowed, one rejected | + +Third-party pins are tags at least seven days old, as in AGENTS.md; +the `latest` rows follow the hosts' own releases so that a new release +that breaks the extension shows on the Monday run. + +**Local runs** in the container, each script as CI calls it: all pass. +The packaged e2e ran 4 of 4 in both modes (repository and installed +package); the keystore round trip ran through gnome-keyring under +`dbus-run-session`. Zed stays a manual check: it drove only through +xdotool on Xvfb with software Vulkan, which reads screenshots rather than +text. JetBrains and Xcode cannot be installed here. + +**Drills** (each broke one thing, the check failed, then restored): + +| Drill | Break | Result | +| ----- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| H1 | the installed agent bundle patched to offer no `allow_once` | Emacs: FAIL on the allow-once checks, exit 1 | +| H2 | code-server started without `museSpark.museBinaryPath` | code-server: FAIL, exit 1 | +| H3 | the persona file renamed without "persona" | Jupyter: FAIL, no Muse Spark persona to talk to, exit 1 | +| H4 | `FAKE_MUSE` pointed at a missing script | Neovim: FAIL, exit 1 | +| H5 | Theia's sidebar opened by clicking its icon instead of Ctrl+Esc | "FAIL theia: the sidebar, started with Ctrl+Esc: no visible Muse Spark composer", tab ok | + +**First CI run** (Hosts run 36222382972 on this branch's merge of main, +2026-09-26): all 12 jobs passed, among them the agent package with the +key's round trip on Linux (gnome-keyring), macOS (the job's own keychain) +and Windows (Credential Manager), and code-server and VSCodium at the +floor and the latest. + +The packaged suite starts the agent as `node /muse-spark-code-acp/dist/acp.js`, +the form `docs/acp.md` now gives for Windows editors that cannot start +npm's `.cmd` launcher (Node itself refuses to spawn a `.cmd` without a +shell), so that form is checked on all three platforms. + +H5 is the Theia `onView:` gap recorded under M62b: the check keeps the +workaround README gives, and fails if the view needs it and does not get +it. + +## The key store on the owner's rigs (2026-09-27) + +`test/hosts/keystore.sh` (no key, `auth set` from a pipe, `status`, +`clear`, no key) had run on GitHub's runners and, by hand, only through +gnome-keyring. It ran on the owner's machines with the agent packed from +`integrate/pr32` (`muse-spark-code-acp-0.9.1.tgz`, from the production build of +the tree committed as the merge, `bc210be`) and a portable Node 22.23.3, with the script's own made-up +key and nothing else; no model was called. + +| Rig | Store | How it ran | Result | +| ---------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| WIN-11-VM, Windows 11 (10.0.26200) | Credential Manager, the logged-on user's | the package installed on this PC into a scratch prefix (its `keyring-win32-x64-msvc` binding) with `node.exe` beside the launcher, copied over SSH; `keystore.sh` under busybox-w32 1.38 `sh`, started as a one-off scheduled task in the owner's console session | `ok the key went into the credential store and out again`: stored, reported, removed, gone | +| Mac mini, macOS 15.7.4 (Intel) | a keychain of the run's own, as hosts.yml does | Node's darwin-x64 archive fetched on the Mac and checked against `SHASUMS256.txt`; `npm install --global --prefix` of the package there (`keyring-darwin-x64`); the run's keychain created, unlocked and made the default for the run, the owner's search list and default put back after | `ok the key went into the credential store and out again`; keychain settings as before | + +Found on Windows: a session opened over SSH with a key cannot use +Credential Manager. `cmdkey` answered "Credentials cannot be saved from this +logon session", and the agent, over the same SSH session, said for `auth +status`, `set` and `clear` alike: "This computer's credential store cannot be +used (Couldn't access platform storage: Windows +ERROR_NO_SUCH_LOGON_SESSION)", exit 1, nothing stored. That is the agent +refusing rather than falling back to a file (D61); `docs/acp.md` now says +to store the key from a desktop session. The same sentence then speaks of +Linux's Secret Service, which does not help a Windows user; left as it is, +since it is one table string in 15 languages and says nothing wrong. + +Left behind: nothing. On the VM the scheduled task was deleted and the +folder removed; `schtasks /query` finds no task and `tasklist` no `node.exe` +or `busybox64.exe`. On the Mac the run's keychain was deleted, the folder +removed, the search list and default read back as the single login +keychain, and the login keychain holds no `Muse Spark Code (Unofficial)` +item. The Kubuntu VM, where gnome-keyring had run, was not needed again. + +## The Codex review of a209130 (PR #32) + +Recorded 2026-09-28. Four P1 findings on the ACP code; each checked +against the code and the rule it cites before anything changed. + +| Finding | Verdict | Change | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `META_API_KEY` in the agent's environment counted as a sign-in | Not changed. Rule 8 keeps the key stored with `auth set` out of every child process, and it is; a `META_API_KEY` the user set is the CLI's own documented credential, inherited untouched as the extension does (PLAN.md §1, the M7 billing decision; `buildChildEnvironment` has no key input). The agent counts it as the panel's sign-in gate does, and `docs/acp.md` says so. | none | +| Form answers used without a check (rule 7) | Partly right. The SDK's `isAccept` already refused a list holding anything but text; an unoffered option, one picked twice, or a count outside the question's bounds went through. | `src/acp/questions.ts`: a zod schema for the response and each field; every field must fit its question or the questions are declined, as the form made each one required. The agent logs the decline in fixed words | +| The CLI's error text in the agent's log (rule 8) | Right. Five log lines about a backend call (skills, an approval, a question, a decline, a cancel) printed the error's message, which for Muse Code is the CLI's. | those five through `failureForLog`: an MSP error by its kind and code, anything else by its type. The three about the editor's own requests keep the editor's message | +| A resumed session keeps the mode it last had | Right. Muse Code and the Model API backend both restore a session's approval mode; the agent told the editor its starting mode without setting it, so an editor showing Manual could run under Edit automatically or Bypass. The panel sets its mode after a resume (`conversationController.ts`, `adopt`). | `loadSession` (load and resume) sets the starting mode before any history is replayed; a backend that refuses it fails the load and the session is let go | + +| Drill | Result | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| the offered-option check removed | exit 1: "declines a form whose answers do not fit the questions", "declines a form whose answer is not one of the options it offered" | +| the approval failure logged by its message | exit 1: "logs a backend failure by its MSP kind and code, never the CLI's message" | +| no mode set on load | exit 1: "loads a session with its history replayed …", "fails a load whose mode the backend refuses, and lets that session go" | + +The integration commit on top (`docs/certification/pr32-integration.md`, +"Codex on `a209130`") closes each class further. Credential variables +reach Muse Code's processes only: `META_API_KEY` still counts as its +credential, but no shell command, hook, git or helper the agent runs +sees it. Every failure the agent logs, the editor's included, goes +through `failureForLog`. A load also sets a listed model and the effort +shown. A form's single choice takes only an offered option (the form +sends it as `oneOf`), and a multiple choice without bounds needs at +least one, as the panel's card does. + +## Left for later + +- M63b: the other ACP clients installed and driven, their versions + recorded in `hosts.md`. Zed, JetBrains and Xcode cannot be installed in + the container. +- M63c, the rest: file access through the client (`fs/*`), so the Model + API backend sees unsaved buffers, after M46–M56 (it needs the session + threaded through the Model API backend's tools); the ACP Registry + (Q65). +- The first Open VSX and npm publish: the release workflow's new jobs run + on the next `v*` tag. The owner has set `OVSX_PAT`; the publish also + needs the Eclipse publisher agreement signed and the `RandyNorthrup` + namespace created. Until `NPM_TOKEN` is set, the npm job writes in the + run's summary that it skipped. diff --git a/docs/certification/pr32-integration.md b/docs/certification/pr32-integration.md new file mode 100644 index 00000000..4337b15c --- /dev/null +++ b/docs/certification/pr32-integration.md @@ -0,0 +1,757 @@ +# PR #32 joined with main after M57 and M58 + +Recorded 2026-09-27 on branch `integrate/pr32` (PR #32's head, its earlier +merge of main `faace73` and the Open VSX namespace step, `2559a9b`), merging +main `72fa907`: the 0.9.0 release fixes, 0.9.1, M57 (the Model API backend +as its own bundle) and M58 (a popup before every paid use). PLAN.md D6 and +D62 amendments, M63. + +## The merge + +Six files conflicted, all prose or lists: `AGENTS.md`, `CHANGELOG.md`, +`PLAN.md`, `README.md`, `docs/certification/README.md`, `package.json` +(the `cycles` entries). Both sides were kept: + +- **`package.json`**: `npm run cycles` follows four entries, the extension, + the Model API bundle (M57), the webview and the ACP agent. +- **`CHANGELOG.md`**: main released 0.9.0 and 0.9.1 after PR #32 branched, + so git placed PR #32's four "Added" entries (the host API gate, the ACP + agent, Open VSX and npm publishing, the host checks) and its "VS Code + 1.99 or newer" entry inside `[0.9.0]`. They are moved to `[Unreleased]`, + where they belong: none of them shipped in 0.9.0. +- **`PLAN.md`**: D48 before D60–D62; the open questions with main's Q6 and + PR #32's Q60–Q65; M57 before M60–M66; the gates table with main's bundle + split and PR #32's host API and hosts rows, the cycles row with four + entries and the aggregates with `check:host-api`. +- **`README.md`**, **`AGENTS.md`**, the certification index: main's rows + and wording, with PR #32's `check:host-api`, `package:acp`, `test/hosts/` + and ACP entries added. + +What merged without a textual conflict but no longer fitted: + +| Where | What broke | Fix | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/build.mjs` | M57's `modelApiOptions` used `NODE_TARGET`, which PR #32 had split into `HOST_NODE_TARGET` (node20.18, the 1.99 floor) and `AGENT_NODE_TARGET` | `dist/modelApi.js` targets `HOST_NODE_TARGET`, as the activation bundle it is loaded beside | +| `src/runtime/backends.ts` | `confirmSubagentTask` (gone with M58); no `bundlePath` (required since M57) | `allowsPaidUse` and `isPaidUseRemembered` through the agent's paid-use questions; `bundlePath` is `dist/modelApi.js` beside `acp.js` (below) | +| `src/acp/translate.ts` | the approval subject's `paidFeature` (removed by M58, a paid call no longer gets an approval) | the approval title no longer names a price; a paid row still does | +| `src/acp/agent.ts` | `error instanceof PromptSettledError`, which M57's lint rule refuses (the class may be the Model API bundle's copy) | `isPromptSettledError` | +| `test/unit/helpers/fakeModelApi.ts` | M57's integration test loads it in VS Code; it used `Promise.withResolvers`, which the integration project (ES2023, PR #32's floor) and Node 20.18 lack | a plain promise, as M62 did in the host | +| `test/unit/helpers/modelApiBundle.ts` | built the bundle for `node22` | `node20.18`, the build's target since PR #32 | +| `docs/ide-compatibility/host-api.md` | stale: M58's popup (`MessageItem.title`, `isCloseAffordance`) and M57's `node:module` | regenerated: 200 VS Code APIs, 13 files importing `vscode`, 17 Node built-ins; `modelApiEntry.ts` added to the portable list | +| `l10n/` | the three strings of the agent's first-prompt price question | removed from `en.ts` and the 14 tables with the question (below); no new string: the agent's questions reuse the popup's own words (`paidUseQuestion`), already in all 15 languages. `check:l10n`: 0 | + +## The agent and M57: `dist/modelApi.js` (PLAN.md D6 amendment) + +The runtime's `ModelApiBackendManager` gets `bundlePath: +/modelApi.js`, the file the `.vsix` ships, and the agent's package now +ships it too (`scripts/package-acp.mjs`), with its packages in the agent's +third-party notices. The alternative, the entry bundled into `acp.js` and +handed over with `loadBundle`, was a second build of the same backend in a +second file. `check-bundle-split.mjs` now reads `dist/meta-acp/acp.json` +and fails when `acp.js` carries a lazy file or the entry; +`check-host-api.mjs` lists `modelApiEntry.ts` as portable. `acp.js` went +from 874.1 KiB (the joined tree before M57) to 713.2 KiB. + +## The agent and M58: each paid use asks in the editor (PLAN.md D62 amendment) + +- **One consent, the panel's.** `AcpPaidUse` (`src/acp/paid.ts`) builds the + core's `PaidUseConsent` for the folder and session of each use. A feature + is on only with its flag; subagents, scheduled prompts and Muse Voice have + none, so they are denied without a question. +- **The question.** `session/request_permission` in the conversation the use + is for: a `tool_call` row `paid-use-` titled and described by + `paidUseQuestion` (the popup's words, moved from `paidHost.ts` into + `paidConsent.ts` so both say the same), options `allow_once`, + `allow_always` (only with `--trust-workspace`) and `reject_once`. A + cancel, an option not offered or a failed request is Deny; the row ends + `completed` or `failed`. +- **Which conversation.** `ModelApiHostDeps.allowsPaidUse` gained a third + argument, `sessionId`: the Model API host serves every conversation of a + folder, and the agent must ask in the right ACP session. A child task's + use carries its parent's id (`askingSessionId`). The extension ignores + it. The agent denies when it holds no such session or no client attached. +- **"Always".** `src/runtime/paidGrants.ts`: `acp/paid-uses.json` in the + agent's data folder, the folder's hash (`workspaceKey`, shared with the + sessions folder) to feature names, validated with zod, read at every + question, replaced atomically and one change at a time. At start + (`serve`), a feature without its flag loses its grants in every folder. +- **Removed.** The first prompt's "Turn on" question (`AcpPaidFeatures.settle`, + `confirmPaid`, the `preparing` state and the three `acpPaid*` strings): + with the price named at every use, it would have asked twice before one + prompt. + +## Tests + +| File | What it proves | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `test/unit/acpAgent.test.ts` | no flag or subagents: denied, nothing asked; each use asks in its session with the price and the untrusted options; "always" in a trusted folder stops the asking and is kept; Deny, a cancel, an option not offered and an unknown option deny; a failed request and an unknown session deny; a paid row names its price | +| `test/unit/acpPaid.test.ts` | no asker, or one that throws: deny; "always" per folder, asked again where a hook demands it and not without trust; flags forget grants, a write failure is logged; the options and answers; the grants file: per folder, across processes, serialised writes, forget, damaged content | +| `test/unit/acpModelApi.test.ts` | through the runtime and a built `dist/modelApi.js`: each prompt that may search asks and the search is tallied; Deny sends the prompt without `web_search`; "always" kept in the data folder, honoured by the next agent, dropped by one started without the flag, asked again after | +| `test/unit/acpRuntime.test.ts` | a missing `dist/modelApi.js` fails in the user's words with the path logged; the grants file's place per platform | +| `test/unit/modelApiHost.test.ts` | the host names the conversation of each paid use: two conversations' web search, the spawn, the owner's follow-up and a child's image, the child's asked in its parent's | +| `test/e2e/acpStdio.e2e.test.ts` | a Model API turn through the package's own `dist/modelApi.js` (the laid-out package, and with `MUSE_ACP_PACKAGE_DIR` the installed one) | + +Also run on this tree: the packed agent (`node scripts/package-acp.mjs`, +23 files, `dist/modelApi.js` among them) installed into a scratch prefix, +the stdio suite against it: 5 of 5. `npm run test:integration`: 10 passing +on VS Code 1.139.1 and 10 on 1.99.0, M57's bundle test among them. + +## Drills + +Each broke one thing, the named check failed, and the file was restored +(content hash compared). + +| Drill | Break | Result | +| ----- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| P1 | `AcpPaidUse.isOn` true for every feature | exit 1: "denies without asking a feature it has no flag for, and subagents always" | +| P2 | the agent answers "once" for a session it does not hold | exit 1: "denies when the client cannot answer, or the session is not one the agent holds" | +| P3 | "Allow always" offered without `--trust-workspace` | exit 1: "asks before each use in its session, naming the price; Allow once allows that use only" | +| P4 | "Allow always" taken although it was not offered | exit 1: "denies on an option it was not offered, and keeps nothing" | +| P5 | `forgetUnflagged` forgets nothing | exit 1: "keeps "Allow always" for a trusted folder until the agent starts without the flag (M58)" | +| P6 | `askingSessionId` is the child's own id | exit 1: "asks each paid use in its conversation, a child's in its parent's (M58, PLAN.md D62)" | +| P7 | no asker attached answers "once" | exit 1: "denies every use until the agent attaches its way to ask" | +| P8 | the grants file keeps names that are not paid features | exit 1: "counts a damaged file, or names that are not paid features, as no grants" | +| P9 | a hook's demand to ask dropped (`requiresAsking` false) | exit 1: "keeps "always" per folder, asks again where a hook demands it, and not without trust" | +| B1 | the runtime hands the manager the entry module (`loadBundle`), bundling the backend into `acp.js` | `check-bundle-split.mjs` exit 1: 21 problems, "dist/acp.js carries src/core/backends/modelapi/ModelApiHost.ts, …" | +| B2 | `modelApiEntry.ts` imports a `vscode` type | `check-host-api.mjs` exit 1: "src/host/backend/modelApiEntry.ts reaches `vscode`", "Uri is a VS Code type" | +| B3 | `scripts/package-acp.mjs` without `modelApi.js`, packed and installed | the installed-package suite: 1 failed, 4 passed, "ships the Model API backend beside the agent" (`MODULE_NOT_FOUND`) | + +## The agent's budget (PLAN.md D6 amendment) + +After the merge, from the production build's metafile: + +| Bundle | Size | Budget | +| ---------------------- | --------- | --------------------- | +| `dist/extension.js` | 432.5 KiB | 600 KiB, unchanged | +| `dist/modelApi.js` | 299.5 KiB | 400 KiB, unchanged | +| `dist/searchWorker.js` | 15.2 KiB | 50 KiB, unchanged | +| `dist/webview/main.js` | 751.7 KiB | 900 KiB, unchanged | +| `dist/acp.js` | 713.2 KiB | **850 KiB** (was 800) | + +`acp.js`: zod 445.2 KiB (257.6 of it the 64 locale files of the classic API +the ACP SDK imports, 185.0 its core and classic API, 2.5 the panel's +`zod/mini`), the English table 58.6, the ACP SDK 53.7, the Muse Code SDK +14.7, and the engine, `src/acp` and `src/runtime` about 155. 713.2 × 1.15 = +820.2, rounded up to 850. Gzipped, `acp.js` is 175.1 KiB; the packed agent +(`muse-spark-code-acp-0.9.1.tgz`) is 611,034 bytes. + +Drill: the agent's budget set to 700 KiB, `check-bundle-size.mjs` exit 1, +"OVER dist/acp.js: 713.2 KiB (budget 700 KiB)"; restored, "ok … (budget 850 +KiB)". + +## Networks and proxies (`docs/acp.md`, PLAN.md Q66) + +What reaches the agent's two network paths, read from the code and then +measured on this machine (Windows 11) with a throwaway script: + +- **The code.** The agent hands the Model API backend + `globalThis.fetch` (`src/runtime/main.ts`), Node's own `fetch`, with no + dispatcher of its own. Muse Code's manager gets no editor proxy + (`NO_EDITOR_PROXY` in `src/runtime/backends.ts`) and no extra variables, + so `muse serve` inherits the agent's environment, with loopback added to + `NO_PROXY` whenever a proxy is set (`withLoopbackBypass`, M56). +- **The agent's `fetch`, measured.** A local proxy on 127.0.0.1 logged each + request line and answered `CONNECT` with 502, so nothing left the + machine; the target, `https://muse-probe.invalid/`, cannot resolve, so a + direct attempt fails with `ENOTFOUND`. Each case ran in a fresh Node + process with only its own variables: + + | Environment | 22.0.0 | 22.20.0 | 22.21.0 | 22.23.3 | 24.0.0 | 24.5.0 | 24.20.0 | + | ------------------------------------------------ | ------- | ------- | ------- | ------------------ | ------- | ------ | ------------------ | + | `HTTPS_PROXY` only | direct | | | direct | | | direct | + | `https_proxy` only | direct | | | direct | | | direct | + | `HTTPS_PROXY` + `NODE_USE_ENV_PROXY=1` | direct | direct | proxy | proxy | proxy | proxy | proxy | + | `https_proxy` + `NODE_USE_ENV_PROXY=1` | direct | | | proxy | | | proxy | + | `HTTP_PROXY` + `NODE_USE_ENV_PROXY=1` | direct | | | proxy | | | proxy | + | `HTTPS_PROXY` + `NO_PROXY=.invalid` + the switch | direct | | | direct | | | direct | + | `ALL_PROXY` + the switch | direct | | | direct | | | direct | + | `HTTPS_PROXY=http://user:pass@…` + the switch | direct | | | proxy, credentials | | | proxy, credentials | + | `HTTPS_PROXY` + `NODE_OPTIONS=--use-env-proxy` | refused | refused | proxy | proxy | refused | proxy | proxy | + + "proxy": the proxy logged `CONNECT muse-probe.invalid:443` and `fetch` + failed with "Proxy response (502) !== 200 when HTTP Tunneling"; + "credentials": a `Proxy-Authorization` header came with it; "direct": + the proxy saw nothing and `fetch` failed with `ENOTFOUND`; "refused": + Node would not start ("--use-env-proxy is not allowed in NODE_OPTIONS"); + an empty cell was not run. + +- **Certificates, measured.** A local HTTPS server with a throwaway + self-signed certificate: without a variable every Node refuses it + (`DEPTH_ZERO_SELF_SIGNED_CERT`); with `NODE_EXTRA_CA_CERTS` naming it, + `fetch` answers 200 on 22.0.0, 22.14.0, 22.15.0, 22.23.3 and 24.20.0. + `NODE_OPTIONS=--use-system-ca` is refused by 22.0.0 and 22.14.0 and + accepted from 22.15.0; whether it then trusts a root in the operating + system's store was not exercised (that needs a root installed there). +- **Muse Code** reads `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` and + `NO_PROXY`, trusts the operating system's store, and takes + `SSL_CERT_FILE` or `SSL_CERT_DIR` in its place, as recorded for the + extension in M56 (`docs/certification/m56.md`); the agent changes + nothing of that, and hands it no VS Code setting. + +The owner's ruling on Q66 (2026-09-27): loud, not re-routed. Recorded +below, "Q66: the limit made loud". + +## The key store on the owner's rigs + +`test/hosts/keystore.sh` with its made-up key, on the agent packed from this +branch: Windows 11 VM (Credential Manager, in the owner's console session) +and Mac mini (a keychain of the run's own): both `ok`, nothing left behind. +Over SSH with a key, Windows refuses Credential Manager +(`ERROR_NO_SUCH_LOGON_SESSION`) and the agent stores nothing; now in +`docs/acp.md`. Details in `m63.md`, "The key store on the owner's rigs". + +## The gate + +`npm run quality` on this branch at `13ee7d4` (Windows 11, Node 24.20.0), +the tree with main's plan (PR #50), PR #49 at its merged head `5184f26`, +rule 8 and the review's fixes: exit 0. Main's `c42c4d5` (PR #49's merge) +has the same tree as `5184f26`, so merging it changed no file. Earlier +runs passed at `83a4530` (Q66) and `87383c7`. The first full run, on +`e231349`, failed at SAST on the spawn behind the agent's `login`, PR #32's +own code, never through semgrep before (its container could not fetch the +rules, `m63.md`); annotated with its reason and registered in PLAN.md §8 +(`87383c7`), and that run is the suppression's drill. A run on `f8063b8` +failed at `check:host-api` (PR #49's code added one API); the record was +regenerated. + +| Step | Result | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `format:check`, `lint`, `typecheck` | exit 0 (PSScriptAnalyzer findings: 0; five projects, the host at ES2023) | +| `check:l10n` | 14 tables, 93 manifest strings, 255 source files; 0 problems | +| `check:host-api` | 201 VS Code APIs, 13 files importing `vscode`, 17 Node built-ins, 57 theme variables; 0 problems | +| `deadcode`, `cycles`, `duplication` | exit 0; no circular dependency; 0 clones | +| `test:unit` | 188 files passed, 2 skipped; 2,873 tests passed, 23 skipped; coverage 94.75 % statements, 90.07 % branches, 96.38 % functions, 94.72 % lines | +| `build` | `extension.js` 448.6 of 600, `modelApi.js` 301.2 of 400, `acp.js` 724.8 of 850 KiB; the split holds for both loaders; notices: 75 packages | +| `security:audit` | 0 advisories, 0 exceptions | +| `test:a11y` | 336 pages (84 scenarios × 4 themes), 0 violations, 0 undecided | +| `security:secrets` | 337 commits scanned, no leaks | +| `security:sast` | 287 rules on 450 files: 0 findings | +| `test:integration` | 10 passing on VS Code 1.139.1 and 10 on 1.99.0 | + +The tail: + +```text +> muse-spark-code@0.9.1 security:secrets +> gitleaks git --redact --no-banner . + +INF 337 commits scanned. +INF no leaks found + +> muse-spark-code@0.9.1 security:sast +> node scripts/sast.mjs + +Ran 287 rules on 450 files: 0 findings. +exit=0 +``` + +Not run here: hosts.yml and forks.yml (CI only; they run on the pull +request), and no live model call was needed. + +## Q66: the limit made loud + +The owner (2026-09-27): the agent does not re-route or add undici; make the +limit loud and the advice correct. + +- **The warning** (`src/runtime/proxyWarning.ts`, logged by `serve` in + `src/runtime/main.ts`): on the Model API backend, when `HTTPS_PROXY`, + `https_proxy`, `HTTP_PROXY` or `http_proxy` is set (each named once; on + Windows the two spellings are one variable) and Node's switch is off, + one line says the requests go to Meta directly and to set + `NODE_USE_ENV_PROXY=1`. The switch counts as on for `NODE_USE_ENV_PROXY` + exactly `1` (measured: `true` and `0` do nothing), or `--use-env-proxy` + in `process.execArgv` or `NODE_OPTIONS`. On a Node without the switch + (below 22.21 on the 22 line, 23, anything older; measured 23.11.1: none), + the line names the running version and what would work, even with the + switch set. Nothing is said without a proxy variable, on the Muse Code + backend (Muse Code reads the variables itself), or once the switch is on. + Only the variables' names are logged, never an address. +- **The advice** (`src/core/networkFailure.ts`): `networkFailureMessage` + takes `NetworkAdvice`, `'vscode'` by default. The runtime's manager passes + `networkAdvice: 'agent'` down to the bundle's `ModelApiClient`, and the + same classifier then returns `acpNetworkUntrustedCertificate`, + `acpNetworkProxyCredentials` or `acpNetworkUnreachable`, which name the + agent's environment (`NODE_EXTRA_CA_CERTS`, `--use-system-ca`, + `HTTPS_PROXY`, `NODE_USE_ENV_PROXY=1`). A proxy's refusal names no setting, + so both hosts share it; the extension's advice is unchanged. The three + strings are in `en.ts` and the 14 tables, translated; `check:l10n`: 0 + problems. +- **Also**: the runtime takes `sleep` from `main.ts`, so a test can run the + client's network retries without waiting; the fake Model API can put a + socket code under a failed fetch, as Node's does. + +Tests: `acpProxyWarning.test.ts` (18: no variable, ALL_PROXY only, the +Muse Code backend; each of the four variables, the value never logged; +the switch by variable, flag and `NODE_OPTIONS`; `true`, `0`, `yes` and a +look-alike flag still warn; Node 22.0.0, 22.20.0, 23.11.1, 20.18.3 and an +unreadable version are too old even with the switch; 22.21.0, 22.23.3, +24.0.0, 24.20.0 and 25.1.0 have it; one spelling per variable on Windows); +`networkFailure.test.ts` (the agent's advice for a certificate, a proxy +wanting credentials and a real refused connection, never naming VS Code; +the refusal shared; the extension's advice as before); +`acpModelApi.test.ts` (a refused connection through the runtime, the +built bundle and the ACP client: the prompt fails with the agent's advice +and not `http.proxy`); `acpStdio.e2e.test.ts` (the built agent's stderr: +the warning with `HTTPS_PROXY` on the Model API backend, none with +`NODE_USE_ENV_PROXY=1`, none on the Muse Code backend). + +| Drill | Break | Result | +| ----- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Q1 | `--use-env-proxy` in `NODE_OPTIONS` not read | exit 1: "says nothing once the switch is on: the variable, the flag, or the flag in NODE_OPTIONS" | +| Q2 | every Node taken as one with the switch | exit 1: 4 of "warns that Node … is too old for any proxy, even with the switch on" | +| Q3 | any non-empty `NODE_USE_ENV_PROXY` taken as on | exit 1: "still warns for a switch Node does not take: only "1" turns it on" | +| Q4 | `serve` does not log the warning | the stdio suite, exit 1: "says at start that a proxy will not be used by the Model API backend, …" | +| Q5 | Windows spellings not merged | exit 1: "names each variable set once: both spellings on Linux, one on Windows, where they are one" | +| Q6 | the runtime passes `networkAdvice: 'vscode'` | exit 1: "says what to set in the agent's environment when Meta cannot be reached (Q66)" | +| Q7 | the client drops its `networkAdvice` | exit 1: the same test | +| Q8 | the agent's unreachable advice replaced by the extension's | exit 1: "names the agent's environment, never VS Code's settings, for the same failures" | +| Q9 | the default advice made the agent's | exit 1: 5 of M56's tests (the extension's advice), in `networkFailure` and `modelApiClient` | +| Q10 | one table without `acpNetworkUnreachable` | `check:l10n` exit 1: "l10n/ui.de.json: acpNetworkUnreachable: missing" | + +## Main's plan (PR #50) and the sign-in fix (PR #49) joined, 2026-09-28 + +Main `ba82f43` (PR #50: D49, D50, M67–M85) merged first: PLAN.md keeps D49 +and D50 before PR #32's D60–D62, and AGENTS.md rule 12 keeps both the ACP +agent's paid-use sentence and D50's planned TypeSafe exception. Then PR +#49's branch, `fix/cli-sign-in-detection` at `2a324d4`, not yet merged to +main: three conflicts (the certification index, `report.ts`'s imports, +`deviceSignIn.ts`), both sides kept. + +- **Node 20, again.** PR #49's `deviceSignIn.ts` and `accountHost.ts` used + `Promise.withResolvers`, which VS Code 1.99 and 1.100 (Node 20.18) lack; + the host typecheck at ES2023 (M62) refused it. `accountHost.ts` now + races the handshake with the core's `unlessAborted`; the device sign-in + stops through a controller of its own and M62's `settleOnAbort`. PR #49's + 220 sign-in tests pass unchanged. +- **The agent's Muse Code readiness** (`src/runtime/backends.ts`) no longer + asks whether the credential file exists, which `muse logout` leaves + behind, emptied. It counts as the panel's gate does: `META_API_KEY` in the + CLI's environment, or PR #49's `CliAccount` over the file's structure + (`empty` signed out, `inline` signed in), with `account/read` on a + short-lived host where only the CLI can say (a Keychain pointer, any + macOS file but the empty one, an unrecognized one). The agent's user + action is `authenticate`: on macOS a session or a list takes the panel's + passive estimate rather than start `muse serve` each time (after PR #49's + `328efb5`, which asks again on every macOS user action). PR #49's + `5184f26` (a forced Model API backend asks the CLI nothing; a + same-account Keychain re-sign-in counts) merged without a conflict and + changes nothing the agent uses. + `unsupportedHere` (a macOS file on Windows or Linux) is "cannot run" with + the panel's `cliCredentialUnsupported` sentence. `authenticate`, after a + sign-in in the terminal, forgets what the CLI said before (the panel's + Check again). No new wire shape: the verdicts and `account/read` are PR + #49's, from its captures. + +Tests: over stdio, the agent asks for sign-in after `muse logout` (the file +there, emptied; the old check said ready); in process, against the fake +CLI, the readiness for no file, the logout shell, a browser sign-in (asked +of the CLI on macOS), an unplaceable file (asked once, remembered, asked +again on `authenticate`), a macOS pointer off macOS, and `META_API_KEY`; +the agent passes the recheck only from `authenticate`. + +## The review before pushing (2026-09-28) + +Three read-only reviews of the whole diff since `2559a9b`, one per class +(concurrency and lifecycle; wire evidence and security, the keyring and +the paid-use flow among them; failure paths and docs), then one round of +fixes: + +- **Grants** (`paidGrants.ts`, `paid.ts`): "always" adds only the feature + it allows, merged into the file inside this process's write queue, so a + set read before another change is never written back (two sessions' + answers both kept; a feature another agent forgot not brought back). A + change fails, rather than writing over it, when the file is there but + cannot be read; a question still reads such a file as no grants. +- **An "always" that cannot be kept** (`paidConsent.ts`, both hosts): the + use goes ahead as allowed once, logged as such, instead of failing. +- **Grants lapse only on the Model API agent**: a Muse Code agent has no + paid flags, so starting one beside it no longer clears them + (`RuntimeBackend.forgetUnflaggedGrants`). +- **Rule 7**: the client's answer to `session/request_permission` is parsed + with zod (`permissionResponse`), for approvals and paid uses alike; + anything else is a cancel. +- **Rows**: a paid-use question's row id is a UUID, so a session loaded + again never reuses one still on screen. +- **The busy window**: a prompt is busy, and a cancel ends it without a + turn, while the session's skills are first announced; a failed form + request is declined so the turn goes on. +- **Words**: a missing `dist/modelApi.js` says to reinstall the agent + (`acpModelApiBundleUnavailable`, 15 languages); an unreadable key store + is named as the OS store in the agent's log. +- **Docs**: D62's sign-in bullet (one method, the launched backend's), M63's + status, the command's name in the M60–M66 table, the conflict count + above, `docs/acp.md`'s absolute links (it is the npm README) and its + `authenticate` sentence, the host API counts in CHANGELOG, PRIVACY on the + agent's sign-in read, and a pointer in `m63.md` to the superseded price + question. + +| Drill | Break | Result | +| ----- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| R1 | an add replaces the folder's set | exit 1: "adds to the file as it is when written, never a set read before another change" | +| R2 | a change reads an unreadable file as empty | exit 1: "fails a change on a file it cannot read, rather than writing over it, …" | +| R3 | grants lapse whatever the backend | exit 1: "lets "always" lapse at start only for the Model API agent, which has the flags" | +| R4 | a malformed permission answer passed through | exit 1: "reads a permission answer only in its schema, and anything else as a cancel" | +| R5 | no busy check while the skills are announced | exit 1: "is busy while the skills are first announced, and a cancel then ends the prompt …" | +| R6 | a failed form request not declined | exit 1: "declines a question the form was cancelled on, …" | +| R7 | an "always" write failure thrown | exit 1: "lets an "always" it cannot keep go ahead once, and says so" | +| R8 | the agent's bundle sentence not passed | exit 1: "loads the Model API backend from dist/modelApi.js beside the agent, …" | +| R9 | the store's name not used in the warning | exit 1: "reads an unreadable secret store as no key and says so once (D25)" | +| K1 | a failed grant write rethrown (before R7 moved it) | exit 1: "lets an "always" it cannot keep go ahead once, and asks again next time" | +| R10 | every readiness call a user action (after PR #49's `328efb5`) | the stdio suite, exit 1: "reads Muse Code's readiness from the credential file, and asks the CLI where only it can say (PR #49)" | + +Left as they are, with their reasons: + +- A paid-use question still on screen when its turn is stopped is not + withdrawn: ACP gives an agent no way to cancel its own request, and a + client that sends `session/cancel` answers it cancelled (the spec). A late + answer to it bills nothing; a late "always" is kept. +- Two agent processes adding a grant in the same instant can keep only one + (no file lock); the lost one asks again. A grant that cannot be forgotten + at start (an unreadable or unwritable file) is logged and, while the flag + is off, never honoured. +- The account host's own failure reasons reach the log as their error's + name, as PR #49 logs them everywhere (its rule: a CLI's text may name a + path or an account). + +## Codex on `a209130`: four P1s, closed by class (2026-09-28) + +- **Credential variables.** The extension's `muse serve` inherits the + user's own `META_API_KEY` on purpose (PLAN.md D1's amendment, + `launch.ts`), and counts it as the CLI's credential; its hooks already + get no `*_API_KEY`. The agent now matches that for Muse Code and goes + further for everything else: `takeCredentials` (in `main.ts`, before any + process starts) takes every credential variable (`isCredentialVariable`: + `*_API_KEY` and the names hooks never get) out of the agent's own + environment and hands them to Muse Code's processes only (`muse serve`, + its account hosts, `muse login`, through `getEnvironmentVariables`, as + the extension's `museSpark.environmentVariables` are). The sweep of every + spawn the agent can reach: the shell tool and hooks (`toolIo`, also given + `withoutCredentials`), git (`processGitRunner`, `process.env`), the + Windows job helpers and tree kills (`windowsPowerShell`, `process.env`) + all inherit the stripped environment; the Model API backend runs no MCP + servers; the editor's MCP servers are started by Muse Code with its own + allowlist. AGENTS.md rule 8, D61 and `docs/acp.md` say so. +- **Logs.** Every backend, CLI or client failure in `src/acp` goes through + PR #49's `failureForLog` (its kind and code, never its message). What + stays as it was: the agent's own grants store (our data folder's path + and its code), the keyring's reason in a sign-in message to the user, + and the host's exit description (our own table sentence). +- **Client answers.** The elicitation answer is parsed whole with zod, and + each answer against its question: a single choice one of its options, + free text (non-blank) only where it has none, a multiple choice distinct + options within its bounds (1 when unset, as the panel's card). Anything + else declines the questions. With the permission answers, those are all + the requests the agent makes of the client. +- **Load and resume.** A loaded or resumed session is set to the mode it + is shown in, a model the agent lists (a contributor model it hides is + replaced by the default) and the effort shown; if the backend refuses, + the load fails and nothing is held. The agent advertises no goal; the + plan it sends is the history's own. + +| Drill | Break | Result | +| ----- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| C1 | the shell tool given the agent's environment as it is | exit 1: "runs a shell command with no credential variable in its environment" (a real run) | +| C3 | credentials copied but left in the agent's environment | exit 1: "takes every credential variable out of the agent's own environment, and leaves the rest" | +| C4 | `main.ts` hands the runtime none | the stdio suite, exit 1: "hands META_API_KEY in its own environment to Muse Code only, …" | +| L1 | a backend failure logged as its message | exit 1: "logs a backend failure by its kind, never its message" | +| E1 | a label that is not an option taken | exit 1: "answers nothing on a label that is not an option (rule 7)", "declines a form whose answers do not fit …" | +| E2 | a multiple choice's lower bound dropped | exit 1: "answers nothing for a multiple choice with fewer than it needs", "declines a form whose answers …" | +| E3 | a malformed response read as an empty accept | exit 1: 3 tests, "answers nothing on a response that is not one (rule 7)" first | +| M1 | a loaded session left as the backend restored it | exit 1: "runs a loaded session as it is shown: the mode, a model it lists, the effort" | +| M2 | an unlisted model kept | exit 1: the same test | +| M3 | a refused mode logged and the load let through | exit 1: "fails a load whose mode the backend refuses, and lets that session go" | + +Another session answered the same review on the branch meanwhile +(`7c0ff0c8`, the fake CLI's credential in the captured shape; `83833fe1`, +recorded in `m63.md`). This commit sits on top and keeps its tests, its +decline log line and its mode on load. It goes further where the two +differ: credential variables reach Muse Code only, the editor's failures +are logged by kind too, and a load sets a listed model and the effort as +well. It also differs twice from the other session's form rules. A +single choice takes only an offered option, where the other session +also took text: the form sends it as `oneOf`, so text is not an answer +the form allows. And a multiple choice without bounds needs at least +one pick, where the other session allowed none: this is the panel's +card rule. + +## Codex on `4eb0156c`: two findings, closed by class (2026-09-28) + +- **State the agent shows comes from the backend (P1).** Muse Code's + `resumeSession` puts the model the agent asked for on the handle, since + `session/resume` takes none; the CLI keeps the model the session last + ran on. `matchAdvertised` now asks the backend (`listModels(sessionId)`, + its `isActive`, as the panel's `adopt` does) and keeps that model only + where the agent lists it; otherwise it sets the default. The sweep of + everything the agent shows: the mode is set explicitly on load and + resume (and sent with `session/start`); the effort is set explicitly on + every path; the model on a new session is the one `session/start` + asked for; the commands are `listSkills`' answer; the replayed history + and plan are the backend's. The Model API host's handle and + `listModels` both hold the session's real model, so it answers the same + way. The agent forks nothing. +- **A session that fails setup is let go (P2).** `adopt` replaces + `register`. A new, loaded or resumed backend session is set up (the + effort; the mode, model and effort, then the replay) before the agent + holds it, so no request finds it and no id reaches the client until + then. Any failure there disposes it, including a handle whose events + cannot be followed. +- **Reviewed before the push (Grok, `78a74430`): 1 P1, 2 P2, all + held.** Both hosts hand back a session they already hold, retained + (`MuseCodeHost.track`, `ModelApiHost.revive`). A first cut kept the + held wrapper until the reload was set up. So a failed reload had + already set the shared session to the starting mode while the kept + wrapper still showed its own, both wrappers followed the session + during setup, and a let-go wrapper could still decide an approval it + was waiting on. Now: + + - `adopt` lets the held wrapper go before anything runs on the session + they share, and a failed reload leaves nothing held for that id; the + editor loads it again. + - A wrapper let go (closed, loaded again, never set up) decides + nothing more. A late permission answer, form answer or form failure + is dropped; a late paid-use answer is a denial. + - A prompt it was running ends `cancelled`, where it never answered + before. + - The fake host could not show this, as it made a new session on each + resume. The reload tests now hand the held one back (`retainOnResume`, `onNextResume`). + +- **Grok's second look (`5e2b85c3`): 2 P1, both held.** + + - The agent told the editor a prompt had stopped without stopping its + turn. Muse Code's `dispose` sends only `task/stopAll`, and a reload + shares the session, so the turn went on unwatched. `release` (which + replaces `dispose`) now stops following the session at once, waits + for a turn still being started, and cancels it on the backend; a + turn that failed to start has nothing to stop. + - A load still being set up was not let go by a newer load of the same + session, so both followed it. `adopt` now keeps the loads being set up + by id. A newer load or a close lets them go too, and a load let go + during its setup fails rather than being held. + - A session let go changes nothing more on the backend (`ensureHeld` + before each mode, model and effort write and before a replay), so a + late setup cannot undo what the newer load set. + - `session/close` of an id neither held nor being set up is refused, + as before. + +- **Grok's third look (`ca263c53`, pushed first as the gate had + passed): 1 P1 and 6 P2s; the P1 and 3 P2s held, and the test double + was taken up.** + + - The P1: a reload built its replacement, which follows the session at + once, before the held one's release had sent `turn/cancel`, so Muse + Code's open prompts reached the replacement for the turn being + stopped. `adopt` now waits until nothing holds or sets up that id, + each turn stopped, before the replacement follows the session. It + looks again after each wait, as another load may have started. + - A start that failed may still become a turn: past its 60 s deadline, + Muse Code's `turn/start` is still running. `release` now cancels + after any start is answered, failed or not. + - A released wrapper delivers nothing more to the editor (`deliver`), + so neither a queued history nor a late tool update goes out. + - The backend stopping lets go of loads being set up too, and those + loads fail. + - The fake session now hands a new listener the prompts still open, + as `MuseSession.onEvent` does (`openPrompts`). The reload test holds + the old turn's start, shows the replacement does not follow meanwhile, + and checks `turn/cancel` goes out before it subscribes. + - Not changed, with reasons. A failed `turn/cancel` is logged and the + session still let go: MSP gives the agent no stronger stop through + `AgentSession`, and a wrapper kept only to listen shows the editor + nothing. `cancelQuestions` after a released check: no await comes + between the check (or the event's arrival) and the call, so no + release can fall in between. + +| Drill | Break | Result | +| ----- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| N1 | the handle's model trusted again | exit 1: 3 tests, "runs a loaded session on the model the backend reports, never the one its handle …" | +| N2 | no explicit set where the backend reports no model | exit 1: 2 tests, "keeps a listed model the backend reports active, and sets the default …" | +| R1 | a failed setup keeps the backend session | exit 1: 3 tests, "lets a new session go when its effort is refused, and holds nothing" | +| R2 | the session held before it is set up | exit 1: 3 tests, "lets the held session go before a reload runs on it, and holds nothing …" | +| R3 | a handle whose events cannot be followed kept | exit 1: "lets a session go whose events cannot be followed" | +| R4 | the held session let go only once the reload is set up | exit 1: "lets the held session go before a reload runs on it …", "follows a session loaded again once …" | +| D1 | a let-go session's late approval decided | exit 1: "ends a closed session's prompt cancelled, stops its turn, and its late approval …" | +| D2 | a let-go session's late form answered | exit 1: "neither answers nor declines a question whose form is answered after its session closed" | +| D3 | a let-go session's failed form declined | exit 1: "… whose form is failed after its session closed" | +| D4 | a let-go session's late paid-use answer allowed | exit 1: "denies a paid use answered after its session closed" | +| D5 | a let-go session's prompt left unanswered | exit 1: "ends a closed session's prompt cancelled, …" | +| D6 | a let-go session's prompt goes on to its turn | exit 1: "ends a prompt cancelled when its session is closed while the skills are announced" | +| R5 | a load being set up not let go by a newer load or a close | exit 1: "lets a load still being set up go for a newer load of the same session", "… closes that session …" | +| R6 | a load let go during its last setup step held anyway | exit 1: "lets a resume go when the editor closes that session while its effort is being set" | +| H1 | a session let go keeps writing to the backend | exit 1: "lets a load still being set up go for a newer load of the same session" | +| C1 | a let-go session's turn left running | exit 1: 3 tests, "ends a closed session's prompt cancelled, stops its turn, …" | +| C2 | the turn cancelled before it started, or when it never did | exit 1: both "closed while its turn is being started, it …" | +| C3 | a prompt whose turn fails to start after a close answers an error | exit 1: "… it has nothing to stop when the turn fails to start" | +| X1 | a close of a session not held answered as done | exit 1: both "lets a resume go when the editor closes that session while its … is being set" | +| G1 | the replacement follows before the held turn is stopped | exit 1: "follows a session loaded again only once its running turn is stopped" | +| G2 | a turn whose start failed not stopped | exit 1: "closed while its turn is being started, … failed to start" | +| G3 | updates still delivered after a session is let go | exit 1: "denies a paid use answered after its session closed" | +| G4 | the backend stopping leaves a load being set up held | exit 1: "lets a load being set up go when its backend stops, and the load fails" | + +## Muse Code's review of `4eb0156c..496fdeed`: two findings (2026-09-28) + +Codex and Grok Build were both at their usage limits, so the last two fix +rounds were reviewed read-only by Muse Code (`muse-spark-1.3-contributor`, +three classes). Both findings were real and are fixed: + +- **P1, a cancel while the turn is starting.** `cancel()` sent the backend's + stop while `sendTurn` was still waiting for its answer, before the turn + existed; the turn then ran to its end, editing and billing, while the + editor was told `cancelled`. `cancel()` now waits for the start to be + answered (a failed start included) and only then stops the turn, as + `release()` already did. +- **P2, a cancel for a session not held.** The `session/cancel` + notification looked the session up with `session()`, which throws for an + id that is closed, still being set up, or unknown; a notification has no + answer, so the SDK only printed "Error handling notification". It now uses + `held()` and stops nothing. + +| Drill | Broken | Result | +| ----- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| C1 | `cancel()` no longer waits for the start | exit 1: "passes a cancel sent while the turn is starting to the backend once the turn exists" | +| C2 | the notification looks the session up with `session()` | exit 1: "ignores a cancel for a session it does not hold" | + +Both restored byte for byte (SHA-256 checked); `acpAgent.test.ts` 61 of 61. + +## Final independent review and repair of `46ba5406` (2026-09-29) + +The owner authorized completing and merging the existing branches, beginning +with this PR, and parallel independent reviews in separate worktrees. Three +reviewers examined concurrency/lifecycle, boundary/security, and failure/docs/ +packaging. The integrator independently reviewed the resulting session and +paid-store repairs; the packaging reviewer separately accepted the integrator's +fixed invocation. Prior green CI and resolved threads did not close these newly +reproduced defects. + +| Finding | Repair and observable evidence | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1: maps lost the old session while its release awaited turn start/cancel, letting a second reload start and then be cancelled by the old one | A per-session release barrier stays visible; request identity is assigned before resume I/O. Older replies fail instead of replacing the newest owner. Real ACP SDK/MSP regressions hold starts, cancels and out-of-order resumes, including close and backend exit. | +| P1: backend exit rejected a completion promise before the prompt awaited it, causing Node's unhandled rejection | Completion outcomes are values until observed by the prompt. Host exit during start/preparation fails the request while the process remains alive; the test observes the separate-turn unhandled-rejection event. | +| P2: cancelled/completed prompt's late paid answer persisted Allow always; ordinary late forms/approval stages had analogous ownership gaps | Answers are bound to their captured prompt and current approval stage. Cancellation, completion, replacement and preparation invalidate stale answers before any grant or backend decision. | +| P2: independent whole-file paid-grant writes restored revoked grants or lost independent additions | `paid-uses.json.d//generation.json` contains a validated UUID generation; `..json` holds that generation's grant. Atomic generation publication/revocation and synchronous before/after reads fail closed. Stale writers cannot replace current-generation records; legacy maps ask again. No lock takeover or unsafe live-record sweep is introduced. | +| P2: Windows npm packaging split an unquoted absolute stage path | npm runs with staging `cwd` and fixed `pack --pack-destination ..` arguments. A real checkout fixture with spaces packages successfully; restoring the original invocation fails with npm looking for the split `package/package.json`; restoring bytes packages successfully again. | +| P2: VSCodium evidence said integration tested an installed VSIX | `hosts.md` now identifies development-extension integration and the fork runners' separate VSIX install/list proof. Packaged code-server/Theia evidence retains its recorded scope. | + +The new MSP test helper uses the existing M6 captured resume envelope, trimmed +to required fields, and the real `MuseCodeHost`, `MuseSession` and SDK transport. +No new wire shape, live model attempt or bill was introduced. + +Focused lifecycle tests: `vitest run test/unit/acpAgent.test.ts +test/unit/acpModelApi.test.ts`, 89 tests passed. The lifecycle suite has 81 tests, +20 new regressions. Nine disposable production mutations each failed on intended +assertions, then source bytes restored and all 81 tests passed again. They break +release barriers, request ownership, completion observation, late prompt answers, +paid preparation, approval-stage identity, preparing/pre-adopt host exits and +failed-resume claim cleanup. No startup failure, timeout or zero discovery counts +as red proof. Receipt and logs: `temp/pr32-lifecycle-drills/receipt.json` and +its named logs; receipt SHA-256 +`e3843a037898f99bd8961cf938f19ab195c9d3d56d026dd26cde4313ec51811d`. +Production session source SHA-256: +`1d2d962cd0ef75b4cf67df203754a6c9c464973acb898db76da8977860e8d5d4`. + +Focused paid-store/runtime/model tests: three files, 60 tests passed. Deliberate +production failures prove independent revocation, generation reread, safe first +initialization, generation-specific names and no-overwrite publication. All +mutations were restored byte-for-byte. One initial publication drill first timed +out and was not counted; its rerun failed the actual lost-grant assertion. +Production paid-store SHA-256: +`e1275911f8ae6d5d2fb0cc8d1a087181b6f5312f675a6ef9b5d7fe6c053ee29a`. +The integrator repeated four guards in a disposable copy with retained logs: +`temp/pr32-paid-grant-drills/receipt.json`, baseline/restored and named mutation +logs. All four exited 1 on their intended assertions, all 22 paid tests passed +before and after, and copied/source SHA-256 remained the value above. A targeted +single-case run exposed filesystem mocks relying on earlier cases' teardown; +the suite now installs actual filesystem defaults before every test as well as +restoring them afterward. The earlier load-related timeout and the pre-fix +single-case failure are retained separately and are not red proof. + +The integrator's final six-suite run (ACP session, paid, runtime, Model API, +translation and shared consent) passed 184 tests in six files, exit 0. Log: +`temp/pr32-final-review/focused-final.log`. The test-setup correction subsequently +passed the disposable paid suite and all four selected red proofs. New Node +imports made the host API record stale (three occurrence-count rows); the +unchanged gate failed, then `check:host-api -- --write` regenerated only those +rows and the record check passed. Its totals remain 201 VS Code APIs, 13 host +files, 17 Node built-ins and 57 theme variables. + +Packaging proof: `temp/pr32-final-review/pack-space-proof.json` and its green, +red and restored logs; exits 0, 1, 0, source bytes restored by SHA-256. This +isolated probe used the existing bundles to verify invocation/path behavior; +fresh final production packaging remains required after the full build. + +All scoped host/unit typechecks, zero-warning lint and diff checks passed. +The final staged-tree full quality gate, staged secret scan, production package +and exact-head hosted checks are pending; no merge readiness is claimed yet. + +### Candidate gate setup and fixture deduplication + +The first full candidate gate stopped at lint because disposable source copies +and runner scripts were still inside the checkout's scan scope. They were moved +with verified absolute paths to the system temporary evidence directory +`muse-goal-evidence-20260929/pr32`; their logs and JSON receipts remain at the +paths above. No ignore, threshold or rule changed. The clean-scratch rerun passed +format, lint, five typechecks, localization, host inventory, dead-code and cycles, +then stopped on eleven duplicated test-setup blocks. Those setups now use shared +fixtures; every assertion and all twenty lifecycle regressions remain. Global +duplication now reports zero clones. + +After this refactor, lifecycle 81/81 and paid 22/22 passed. All nine lifecycle +mutations and four retained-log paid mutations were repeated against the final +fixtures: intended assertion failures, exit 1, exact source restoration, then +81/81 and 22/22 restored green. Updated lifecycle receipt: +`temp/pr32-lifecycle-drills/refactored-receipt.json`, SHA-256 +`8b6e9f6cf9c58a4d5d163d412264d4c56ae9300d52d1354a0421f1faf38d767d`. +Production source hashes above are unchanged. Final test hashes: +`acpAgent.test.ts` — +`4eae474a6f5e84cdf8f34fc2e18c45dc650117e9797badae172031d27e2282e7`; +`acpPaid.test.ts` — +`4ba4dbcd71838fdaebf6dc76559ea4498723b4352bba4cbeccec7630a9982254`. +The final candidate is restaged and the full gate rerun; earlier failed runs +remain historical evidence, not passing gate receipts. + +### Local platform correction (2026-09-29) + +Candidate commit `22f62ed17b28c8ee7ae02e160d05c763592e395c`, tree +`4068b25e20d465395e1a50b4ed7d26f23566dbc0`, passed the final Windows-host +`npm run quality` (exit 0: 2,942 tests passed, 23 skipped; 336 accessibility +pages; security gates clear), staged secret scan and fresh production ACP pack. +Hosted Hosts run `36637040694` and Forks run `36637040482` passed that SHA. +CI run `36637041038` passed Linux but failed macOS and Windows: the test +"preserves a newer explicit grant when an old-generation writer finishes last" +timed out at the existing 5,000 ms limit on both platforms. + +The integrator reproduced that exact timeout on the Windows host by pointing +only the test child process's `TEMP` and `TMP` at an owned directory junction. +The existing grant was written at its canonical path by `fsAtomic`, while the +test held a rename only at its lexical temporary path. The fixture now resolves +its created directory with `realpathSync`; no production code or timeout changed. +The original selected case exited 1 at 5,000 ms; all 22 paid tests then passed, +exit 0, under the same alias. Logs: the system temporary evidence directory +`muse-goal-evidence-20260929/pr32-temp-alias-958733ff22494f9288a4c7e718be1b0a`, +`original.log` and `fixed.log`, with their process exits. This is a reproduced +test-harness failure, separate from the intended production-mutation red proofs. + +The owner's available Mac mini, Kubuntu VM and Windows 11 VM must run the final +candidate before its next push. Independent fixture review, refreshed mutation +proofs, exact-tree full local gates and the next hosted SHA are pending here. + +### Windows 8.3 follow-up (2026-09-29) + +Commit `74f3c2cf8966f016af849d31b29e23467d8abda0`, tree +`00979a65ffccf001401fc721aab789ad4bdbfbd9`, passed full quality on the Windows +host and VM, Mac mini and Kubuntu, plus nine installed ACP stdio tests on each +rig. Fresh CI `36645658244` passed Linux and macOS but Windows again timed out +in the same paid-grant race. The junction tests had missed real 8.3 aliases. + +The native Windows `GetShortPathNameW` read-only probe produced an actual short +path to the owned temporary fixture. JavaScript `realpathSync` retained +`MUSE-G~1/PR32-T~1`; `realpathSync.native` expanded those components. Under that +short `TEMP`/`TMP`, the unchanged committed test reproduced the 5,000 ms timeout +(exit 1). The fixture's native method then passed all 22 paid tests, exit 0. +The atomic writer already uses native resolution through `fs.promises.realpath`; +production code, assertions and timeouts are unchanged. + +Retained logs in the same system temporary evidence directory named above: +`short-original.log`, `short-original.exit`, `short-fixed.log`, `short-fixed.exit`. +All four final full gates must rebind to the new candidate. Windows host and VM +use actual short-name temporary paths; the VM also retains its junction case. +The earlier platform and hosted receipts are historical and do not certify the +new fixture. Independent review and final hosted proof remain pending. diff --git a/docs/ide-compatibility.md b/docs/ide-compatibility.md new file mode 100644 index 00000000..777ae363 --- /dev/null +++ b/docs/ide-compatibility.md @@ -0,0 +1,272 @@ +# Muse Spark Code — IDE Compatibility Plan + +> The owner's plan, kept as handed over on 2026-09-26. The decisions taken +> from it are PLAN.md D60, the work is M60–M66, and the questions it raises +> are Q60–Q64. What the extension asks of its host today is the generated +> record [`ide-compatibility/host-api.md`](ide-compatibility/host-api.md) +> (M60). The links below were not re-read when the plan was filed; each +> target's claim is re-read from its source before its milestone starts. + +**Prepared:** September 25, 2026, America/Los_Angeles +**Project:** [RandyNorthrup/muse-spark-code](https://github.com/RandyNorthrup/muse-spark-code) +**Reviewed baseline:** manifest version 0.8.0, main commit [`bdaede45417ac8dbcaf5f52aa9b3ff307396ab03`](https://github.com/RandyNorthrup/muse-spark-code/commit/bdaede45417ac8dbcaf5f52aa9b3ff307396ab03) +**Status:** Proposed roadmap based on repository inspection and current primary documentation. No additional IDE has been installation-tested or certified during this review. + +## 1. Recommended direction + +Develop Muse Spark Code as one product with a shared agent engine, a reusable React interface, and a small set of integration families: + +1. **The VS Code extension family:** qualify the existing extension in compatible editors and remote workspaces. +2. **The ACP agent family:** expose the shared engine through Agent Client Protocol so participating IDEs can provide their own chat and approval interfaces. +3. **Native host plugins:** embed the shared Muse interface and implement editor services where a dedicated plugin offers a better experience or ACP is unavailable. +4. **External integration for constrained hosts:** provide a terminal or adjacent Muse interface with explicitly limited editor integration. + +The product name remains **Muse Spark Code** across these distributions. Preserve the project's unofficial branding and keep release numbers below 1.0 until the owner explicitly chooses otherwise, consistent with the existing project decisions. [Project instructions](https://github.com/RandyNorthrup/muse-spark-code/blob/main/AGENTS.md), [PLAN.md, decision D44](https://github.com/RandyNorthrup/muse-spark-code/blob/main/PLAN.md) + +The goal is broad coverage of major general-purpose, mobile, scientific, and terminal development environments. A platform can have a credible route without qualifying for identical interface and feature support. This plan therefore specifies both the integration path and the work required to earn a support claim. + +## 2. What the repository already provides + +The code has useful separation already. This is a portability project built on existing boundaries, with additional work around the editor and runtime services. + +| Existing component | Evidence | Portability implication | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Shared backend contract | `AgentHost` and `AgentSession` represent sessions, turns, approvals, cancellation, history, models, usage, and subagent operations. | Keep this as the internal backend boundary. Add external protocols around it. | +| Two backends | Muse Code over Muse Session Protocol; Model API with its own tool execution. | Each adapter must publish results separately for both backends. | +| React interface | `src/webview/main.tsx` mounts the app and calls `acquireVsCodeApi()`. | Extract a host bridge for messaging and persisted UI state; reuse the React components. | +| Validated messages | `src/shared/protocol.ts` intentionally avoids Node, DOM, and VS Code imports. | Suitable foundation for a versioned shared UI contract. | +| Injected services | Conversation controller, credential store, edit review, and tool I/O already accept dependencies. | Some code under `src/host` can move or be generalized; it does not all need rewriting. | +| Desktop/server runtime | Manifest uses `main`, `extensionKind: ["workspace"]`, VS Code `^1.125.0`, Node `>=22`, no `browser` entry, and no virtual-workspace support. | Compatible desktop/server hosts are the first targets. Pure browser hosts require additional work. | +| Build assumptions | Host bundle targets Node 22; webview bundle targets Chrome 128. | Check actual runtime/browser engines in every host. A webview alone does not guarantee compatibility. | + +Repository evidence: [backend interfaces](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/core/agent/agentBackend.ts), [webview entry](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/webview/main.tsx), [message protocol](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/shared/protocol.ts), [manifest](https://github.com/RandyNorthrup/muse-spark-code/blob/main/package.json), [build configuration](https://github.com/RandyNorthrup/muse-spark-code/blob/main/scripts/build.mjs). + +The remaining host work includes active documents and selections, diagnostics, file dialogs, editor diffs, dirty buffers, terminal environments, secrets, settings, persistence, notifications, resource URLs, localization, and native voice helpers. The activation entry point currently wires these services through VS Code. [Extension wiring](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/extension.ts), [webview setup](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/host/views/webviewSetup.ts) + +## 3. Compatibility matrix + +**Priority meanings:** Early = first expansion waves; Next = dedicated adapter after the foundation; Conditional = investigate a specific restriction before making a commitment. These are roadmap priorities, not current support badges. + +### 3.1 Editors and workspaces that can reuse the VS Code adapter + +| Target | Planned delivery and interface | Priority and qualification | +| ---------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| VS Code desktop | Existing extension and full Muse React interface. | Reference implementation throughout the migration. | +| VSCodium | Existing adapter; Open VSX and the project's release VSIX. | Early. Check actual API and Node versions. [Extension documentation](https://github.com/VSCodium/vscodium/blob/master/docs/extensions.md) | +| Cursor | Existing adapter and full Muse interface; Open VSX distribution. | Early. Validate coexistence with its built-in AI, shortcuts, authentication, and editor runtime. [Cursor extensions](https://cursor.com/help/customization/extensions) | +| Kiro IDE | Existing adapter; Open VSX distribution. | Early. Kiro rebases selectively on VS Code OSS, so its actual version must meet our requirements. This claim concerns Kiro IDE. [Migration guide](https://kiro.dev/docs/upgrade-guides/migrating-from-vscode/), [registry](https://kiro.dev/docs/ide/editor/extension-registry/) | +| Positron | Existing adapter and React interface; Open VSX catalog or manual VSIX. | Early. Most VS Code extensions are compatible; qualify Muse specifically. Current default gallery serves the Open VSX catalog through Posit Public Package Manager. [Positron extensions](https://positron.posit.co/extensions.html) | +| Eclipse Theia IDE | Existing VSIX with targeted compatibility adjustments. | Early/Next. Theia implements the VS Code API separately and has documented gaps and stubs. Test behavior, not only method existence. [Install extensions](https://theia-ide.org/docs/user_install_vscode_extensions/), [API comparator](https://eclipse-theia.github.io/vscode-theia-comparator/status.html) | +| code-server | Existing adapter in the server extension host; React UI in the browser. | Early remote target. Agent binaries, files, shell, and credentials belong to the workspace host. [FAQ](https://coder.com/docs/code-server/FAQ) | +| GitHub Codespaces | Existing workspace extension in the remote Node host. | Early remote target. Test authentication, server paths, process dependencies, and persistence. [Remote extensions](https://code.visualstudio.com/api/advanced-topics/remote-extensions) | +| Eclipse Che / OpenShift Dev Spaces | Existing adapter when the chosen workspace editor is Code-OSS. | Next remote target. Support depends on the configured workspace image/editor; a JetBrains workspace uses the JetBrains route. [Che architecture](https://eclipse.dev/che/docs/stable/discover/what-is-che/) | +| Firebase Studio | Existing adapter, Open VSX, runtime packages in workspace configuration. | Next remote target. Qualify extension behavior and installation of the agent beside the workspace. [Workspace customization](https://firebase.google.com/docs/studio/customize-workspace) | +| Google Antigravity IDE | Candidate for a VS Code-adapter prototype. | Conditional. Current retrieved primary documentation establishes the IDE surface, but did not establish an arbitrary-extension installation contract. Verify before promising a VSIX release. [IDE overview](https://antigravity.google/docs/ide/overview/) | + +Keep the VS Code API minimum at `^1.125.0` unless an audit and real-host tests justify a change. Lowering a manifest requirement does not supply a missing API or upgrade the editor's embedded Node runtime. The existing build also needs a runtime that supports the Node features and dependencies it actually uses. [VS Code manifest reference](https://code.visualstudio.com/api/references/extension-manifest), [current project manifest](https://github.com/RandyNorthrup/muse-spark-code/blob/main/package.json) + +### 3.2 IDEs and editors reached through a shared ACP agent + +ACP connects an external coding agent to an editor-provided interface. Implementing an ACP adapter would preserve Muse's agent logic while using the client's chat, tools, and approval presentation. Its optional capabilities must be negotiated. [ACP introduction](https://agentclientprotocol.com/get-started/introduction), [protocol overview](https://agentclientprotocol.com/protocol/v1/overview) + +| Target | Planned route | Interface and conditions | +| ------------------------ | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| JetBrains IDEs | Shared ACP executable through supported AI Assistant versions. | JetBrains AI Chat. Target IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, GoLand, CLion, RustRover, Rider, RubyMine, and DataGrip where the applicable product/version supports this feature. Qualify each product. Current docs say no JetBrains AI service subscription is required and WSL is unsupported for ACP. [JetBrains ACP](https://www.jetbrains.com/help/ai-assistant/acp.html) | +| Zed | Shared ACP executable; custom configuration first, registry distribution later. | Zed's Agent Panel. Current docs prefer the ACP Registry and deprecate extension-provided agents as the main distribution path. [External agents](https://zed.dev/docs/ai/external-agents) | +| Xcode 27+ | Shared ACP executable added as an agent in Intelligence settings. | Xcode's native coding assistant. Xcode 27 release notes explicitly introduce generic ACP support. [Release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-27-release-notes), [setup](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence) | +| Qt Creator | Shared executable configured in the official ACP Client extension. | Qt Creator's chat and change-review interface. Optional MCP Server integration supplies builds and compiler output. Qualify a release containing this extension; reviewed docs identify Qt Creator 20.0.2. [ACP Client](https://doc.qt.io/qtcreator/creator-how-to-use-acp-client.html) | +| Neovim | Shared executable through a maintained ACP client; choose CodeCompanion as the first test target. | Native Neovim client UI. Support the exact client/version; other ACP plugins remain separate qualification targets. [CodeCompanion ACP adapters](https://codecompanion.olimorris.dev/configuration/adapters-acp) | +| Emacs | Shared executable through `agent-shell`. | Emacs interface. Community-client dependency must appear in support documentation. [agent-shell](https://github.com/xenodium/agent-shell) | +| Sublime Text | Shared executable through the community `sublime-acp` package. | Package-provided interface. Test that package's actual behavior; this is not a first-party Sublime ACP promise. [sublime-acp](https://github.com/debjan/sublime-acp) | +| Windsurf / Devin Desktop | Prefer the documented ACP route; investigate full VSIX coexistence separately. | Devin interface. Current ACP availability is plan-dependent; Pro, Max, and Teams are documented. Agent installation is separate, terminal callbacks are absent, and modes use session configuration options. [ACP availability](https://docs.devin.ai/desktop/acp), [custom agents](https://docs.devin.ai/desktop/acp-custom) | + +Windsurf is identified as Devin Desktop in its current documentation. Extension installation guidance is inconsistent across its pages, so the full Muse VSIX remains a qualification task. The documented custom ACP path is a firmer basis for planning. [Rename FAQ](https://docs.devin.ai/desktop/devin-desktop-faq), [extension guidance](https://docs.devin.ai/desktop/recommended-extensions), [getting-started guidance](https://docs.devin.ai/desktop/getting-started) + +**Android Studio is tracked separately below.** Sharing the IntelliJ Platform does not by itself prove that the JetBrains AI Assistant ACP entry point is available in Android Studio. + +### 3.3 Dedicated native adapters + +| Target | Proposed implementation | Initial scope and priority | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| JetBrains full Muse interface | Kotlin/Java plugin, JCEF tool window, shared React bundle, shared agent runtime. | Next. Preserve the Muse interface and custom controls. Use common APIs; add product-specific services only where necessary. Check JCEF availability. [JCEF](https://plugins.jetbrains.com/docs/intellij/embedded-browser-jcef.html), [plugin compatibility](https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html) | +| Android Studio | Shared IntelliJ plugin code, separately built and tested against Android Studio's platform version and browser runtime. | Next, alongside the JetBrains plugin. Android-aware build or project tools can follow baseline chat/context/edit support. [Android Studio plugin development](https://plugins.jetbrains.com/docs/intellij/android-studio.html) | +| Microsoft Visual Studio, Windows IDE | C#/.NET host with editor/solution services and a browser-backed tool window. Assess VSSDK plus WPF/WebView2 first. | Next. Existing VS Code VSIX is not this plugin. Prove the chosen SDK/browser combination before committing. [Tool windows](https://learn.microsoft.com/en-us/visualstudio/extensibility/creating-an-extension-with-a-tool-window?view=visualstudio), [WebView2 WPF](https://learn.microsoft.com/en-us/microsoft-edge/webview2/get-started/wpf) | +| Eclipse IDE, classic | Java/OSGi plugin; SWT Browser and Java/JavaScript bridge; shared agent runtime. | Next. Prototype React rendering, then editor context, changes, diagnostics, and workspace lifecycle. Distinct from Theia. [SWT Browser](https://help.eclipse.org/latest/topic/org.eclipse.platform.doc.isv/reference/api/org/eclipse/swt/browser/Browser.html), [BrowserFunction](https://help.eclipse.org/latest/topic/org.eclipse.platform.doc.isv/reference/api/org/eclipse/swt/browser/BrowserFunction.html) | +| Apache NetBeans | Java module with editor APIs and HTML UI integration. | Next. Prove the real React bundle works in the chosen HTML runtime. [HTML UI API](https://bits.netbeans.org/dev/javadoc/org-netbeans-api-htmlui/org/netbeans/api/htmlui/OpenHTMLRegistration.html) | +| JupyterLab 4 / Notebook 7+ | TypeScript frontend with shared React components; Jupyter server extension connecting the agent runtime. | Next. Use notebook document/cell APIs. Notebook 7 gets its own application tests. [React integration](https://jupyterlab.readthedocs.io/en/stable/extension/virtualdom.html), [server extensions](https://jupyter-server.readthedocs.io/en/latest/developers/extensions.html), [Notebook versions](https://jupyter-notebook.readthedocs.io/en/latest/changelog.html) | +| Spyder | Python/Qt plugin connecting the shared runtime; native interface or validated Qt WebEngine embedding. | Later native wave. Confirm distribution for the supported Spyder installation type; current docs recommend Conda for third-party plugins. [Plugin development](https://docs.spyder-ide.org/current/workshops/plugin-development.html), [installation](https://docs.spyder-ide.org/current/installation.html) | +| RStudio Desktop / Server / Workbench sessions | R addin and `rstudioapi`, with a Shiny Gadget or adjacent shared web interface. | Later native wave. Start with selected code, chat, proposed changes, and document application; account for R-session lifecycle. [RStudio addins](https://docs.posit.co/ide/user/ide/guide/productivity/add-ins.html) | + +Rider's baseline chat/editor plugin can use the IntelliJ frontend; deeper C# semantic features may require its ReSharper backend. Visual Studio's newer out-of-process Remote UI model should not be assumed to accept an arbitrary WPF/WebView2 control. Both are specific implementation gates. [IntelliJ and Rider architecture](https://plugins.jetbrains.com/docs/intellij/intellij-platform.html), [VisualStudio.Extensibility tool windows](https://learn.microsoft.com/en-us/visualstudio/extensibility/visualstudio.extensibility/tool-window/tool-window?view=vs-2022) + +### 3.4 Constrained, external, and conditional targets + +| Target | Credible starting point | Boundary of the current plan | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vscode.dev` / `github.dev` without remote compute | New browser entry point plus an authorized remote runtime or separately designed local bridge. | Current main-only Node extension cannot run here. A browser worker cannot launch the Muse CLI. [Web extensions](https://code.visualstudio.com/api/extension-guides/web-extensions) | +| Xcode 26.3+ without the Xcode 27 ACP route | External Muse runtime/interface using Xcode MCP tools through `xcrun mcpbridge`. | External-agent integration; do not promise an embedded Muse panel. [Xcode external agents](https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode), [26.3 announcement](https://www.apple.com/newsroom/2026/02/xcode-26-point-3-unlocks-the-power-of-agentic-coding/) | +| Vim | Terminal Muse client, then a purpose-built Vim integration if demand warrants it. | Neovim plugin compatibility does not establish Vim compatibility. No native Muse plugin is established by this research. | +| Kate | External tool/terminal integration first; evaluate a native plugin or released ACP client later. | Reviewed official site still lists its ACP work as an open merge request. [Kate development status](https://kate-editor.org/merge-requests/) | +| MATLAB | MATLAB app/add-on with a `uihtml` interface and selected editor workflows. | Conditional app integration. `uihtml` is not proof of full IDE-plugin support; its documented desktop media limitations matter. [uihtml](https://www.mathworks.com/help/matlab/ref/uihtml.html) | +| Replit | Test the future Muse CLI in the project shell or use an adjacent interface. | Current shell/MCP docs do not establish installation of the complete Muse assistant UI. [Shell](https://docs.replit.com/features/workspace-tools/shell), [MCP](https://docs.replit.com/features/mcp/overview) | +| StackBlitz / Codeflow | Runtime experiment or attached supported editor. | WebContainers running Node does not prove support for Muse's native CLI/process assumptions or arbitrary VSIX installation. [Environments](https://developer.stackblitz.com/guides/user-guide/available-environments) | +| CodeSandbox / Ona, formerly Gitpod | Investigate supported remote-editor attachment and runtime execution. | Current retrieved sources did not establish a generic embedded Muse extension route. Keep conditional rather than inheriting older product assumptions. [CodeSandbox](https://codesandbox.io/), [Ona](https://ona.com/) | +| Arduino IDE 2, Code::Blocks, CodeLite, Geany, Notepad++ | Explicitly scoped external-tool or native-plugin feasibility studies if these become priorities. | Watchlist. This review did not establish a full supported route. A shared toolkit or embedded editor is insufficient evidence. | + +## 4. Shared architecture + +### 4.1 Package boundaries + +The following names are proposed boundaries, not files already created in the repository. + +| Proposed package/area | Responsibility | Starting material | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `core` | Backends, agent sessions/events, rules, skills, memory, tools, usage, export. | `src/core` and neutral shared types. | +| `application` | Conversation orchestration, host-independent feature policy, session coordination. | Reusable portions of `src/host/conversation` and backend managers. | +| `contracts` | Versioned UI messages, editor services, capability definitions, schema validation. | `src/shared/protocol.ts` and injected host interfaces. | +| `ui` | Shared React app, localization, presentation, accessible components, theme tokens. | `src/webview` and UI localization tables. | +| `runtime-node` | Agent process lifecycle, filesystem/search workers, shell, local persistence, backend wiring. | Portable host utilities and existing process helpers. | +| `adapters/vscode` | VS Code API implementation and existing marketplace package. | `src/extension.ts` and VS Code-specific host code. | +| `adapters/acp` | ACP protocol translation, client capability negotiation, client I/O where available. | New boundary around `AgentHost`/`AgentSession`. | +| `adapters/jetbrains`, `visualstudio`, and later adapters | Native UI container and editor integration, connecting shared packages/runtime. | New host code in each platform's supported language. | + +Preserve the existing VS Code implementation as the reference client while extracting each boundary. Some host files are already structurally portable. For example, the credential store accepts a small injected interface, while `ChatSurface` currently inherits a VS Code type. Remove these incidental type dependencies rather than duplicating the underlying behavior. [Credential store](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/host/auth/credentialStore.ts), [ChatSurface definition](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/host/views/webviewSetup.ts) + +### 4.2 Reuse the interface through a host bridge + +Replace the direct `acquireVsCodeApi()` dependency with an injected interface for sending/receiving validated messages and saving/restoring view state. Supply host theme tokens, localization, focus/navigation behavior, and resource URLs through adapter boundaries. Keep model credentials outside the React layer. + +VS Code can retain its existing postMessage transport. JetBrains can use JCEF callbacks; Visual Studio can use the selected WebView2 messaging mechanism. Shared components should use Muse theme tokens mapped from each editor rather than assuming every host supplies VS Code CSS variables. Browser compatibility testing must include JavaScript, CSS, clipboard/drag-and-drop, accessibility, and lifecycle behavior. + +The Node engine can remain in-process where a host already supplies an appropriate Node environment. Native IDE plugins will generally connect to a separate packaged runtime. A shared codebase does not require every host to use an identical process arrangement. + +### 4.3 Editor services + +Define explicit services for workspace roots; document URI and identity; active selection; content snapshots; dirty/version state; edits; file/diff navigation; diagnostics; commands and terminals; settings; secrets; persistence; and UI actions. + +Use URI/document identities at the host boundary and resolve local paths only inside the appropriate workspace runtime. Include expected document versions or content checks in edit operations. Multiple IDE windows editing the same workspace need a defined ownership/conflict policy. Language-specific services, such as Rider C# analysis or notebook cells, should be optional extensions to this contract. + +## 5. ACP implementation plan + +Add a proposed entry point such as `muse-spark-code acp`. This command does not exist in the reviewed release. Use stable ACP v1 as the initial interoperability baseline and pin the selected SDK after the project's dependency review. Current official guidance describes v2 as a draft and recommends retaining v1 compatibility. [TypeScript SDK](https://agentclientprotocol.com/libraries/typescript), [v2 draft status](https://agentclientprotocol.com/announcements/acp-v2-draft) + +Implement the following in dependency order: + +1. Initialization, version negotiation, and accurate capability reporting. +2. Authentication handoff to supported Muse credentials and backend selection. +3. Session creation, prompt submission, streamed updates, and cancellation. +4. Tool activity, results, file locations, diffs, and backend approval decisions. +5. Client filesystem/terminal operations where advertised and usable by the selected backend. +6. Session loading/history, available commands, model/mode configuration, usage, and questions where both ends support them. +7. Adapter-specific installation profiles and a tested feature manifest for each client. + +Do not reduce Muse permissions merely because ACP permits an agent to choose when to ask. Preserve the product's actual approval policy. Map choice IDs and cancellation precisely; a declined or cancelled operation must not execute because of a translation default. + +ACP v1 can carry image/audio content and optional structured elicitation, but the editor controls presentation and supported inputs. Custom subagent maps, detailed usage panels, and the exact Muse attachment interface require either explicit client support or the shared Muse UI. [Content types](https://agentclientprotocol.com/protocol/v1/content), [elicitation](https://agentclientprotocol.com/protocol/v1/elicitation) + +Keep protocol responsibilities separate: ACP connects the agent conversation to the editor; MCP supplies tools/resources to the agent; LSP supplies language services. An MCP server does not automatically install Muse as an IDE's coding assistant. Qt Creator demonstrates how an ACP chat and an MCP build/tool service can work together. [ACP introduction](https://agentclientprotocol.com/get-started/introduction), [Qt Creator ACP guide](https://doc.qt.io/qtcreator/creator-how-to-use-acp-client.html) + +## 6. Backend-specific editing and credential boundaries + +### 6.1 File changes must be qualified separately + +The Model API backend accepts injected `ToolIo` services. Muse Code's CLI owns substantial file and command execution itself. Translating an MSP event into an ACP diff reports activity; it does not reroute the original operation through the editor or create an undo transaction. ACP explicitly permits agent-owned execution and makes client filesystem/terminal services optional. [Model API manager](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/host/backend/modelApiBackendManager.ts), [tool I/O](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/host/backend/toolIo.ts), [ACP tool execution](https://agentclientprotocol.com/protocol/v1/tool-calls) + +For Model API mode, evaluate client-mediated reads/writes, while auditing shell and other mutation paths that can bypass them. For CLI mode, verify whether the backend provides usable pre-execution controls or delegation. An after-the-fact event cannot prevent overwriting a dirty buffer. Native undo, safe handling of unsaved changes, and change review are separate capabilities that require real-host tests. [ACP filesystem capabilities](https://agentclientprotocol.com/protocol/v1/file-system) + +If safe shared-workspace editing cannot be established, use an isolated workspace with explicit patch application and conflict checks, or a genuinely enforced read-only mode. Do not silently save or discard unsaved documents. Do not apply a patch a second time when the backend already performed the change. + +A separate checkout stages changes but does not by itself confine a CLI or shell. If isolation is required to prevent changes to the original workspace, enforce that write boundary for backend processes and shell tools, including absolute paths and symlinks. Apply resulting patches through host edits that check current document versions and conflicts. + +### 6.2 Preserve authentication policy during extraction + +The existing project keeps the pasted Model API key in VS Code SecretStorage and does not pass it to child processes; the Muse Code CLI authenticates independently. A new runtime must not silently turn that key into a launch argument, environment setting, webview message, or general IPC field. [Project credential rules](https://github.com/RandyNorthrup/muse-spark-code/blob/main/AGENTS.md) + +The first ACP feasibility build should reuse the CLI's established login. Before adding Model API support to a standalone runtime, record the precise credential ownership design in `PLAN.md`: either the runtime retrieves its own credential from a supported protected OS store, or the key-owning host performs authenticated model requests through a narrowly defined service. Generalizing VS Code-specific storage language to other hosts is an explicit architecture decision, not permission to forward keys to the Muse CLI. Until that decision is implemented and verified, mark Model API support in that adapter as pending. + +Preserve existing paid-feature opt-ins, workspace protections, and supported authentication behavior. Protocol or editor support does not grant a model subscription, provider access, or additional billing permissions. + +## 7. Support levels and feature reporting + +Track **integration type** independently from **release status**. + +| Integration type | User-facing promise | +| ---------------------- | ----------------------------------------------------------------------------------- | +| Full Muse interface | Shared Muse chat interface inside the editor, with the advertised host features. | +| Native agent interface | Muse engine through ACP in the editor's interface, with listed feature differences. | +| External integration | Muse terminal/adjacent interface with the specified editor connection. | + +Release status progresses through **Planned → Prototype → Preview → Supported**. A failing regression can move an affected editor version back to Preview or Unsupported without changing every other adapter's status. + +For each editor/version/backend/OS, record: installation; authentication; streaming; cancellation; permission enforcement; selected/unsaved context; edit handling; diff presentation; native undo; shell execution; diagnostics; history/resume; subagent presentation/control; images; voice; paid features; and remote-workspace behavior. Use explicit values such as tested, partial, unavailable, and unverified. + +A successful installation is the first qualification step. It is not sufficient evidence for editing, credentials, or full feature parity. + +## 8. Rollout and acceptance criteria + +| Phase | Deliverables | Completion evidence | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| A — Compatibility inventory | Actual API/runtime requirements; capability matrix; minimal probes for VSCodium, Cursor, Kiro, Positron, Theia; first ACP client and native browser-host prototypes. | Real-host install/activation results; list of concrete blockers; selected runtime/credential design. | +| B — Shared boundaries | Extract contracts, reusable application services, UI bridge, Node runtime entry, and capability detection while preserving VS Code behavior. | Existing quality gates pass; unchanged VS Code workflows; shared engine can be driven without loading `vscode`. | +| C — VS Code family | Qualify desktop forks; dual marketplace/release packaging; remote Codespaces and code-server profiles. | Per-host test records, repeatable installs, authentication and editing checks, published limitations. | +| D — ACP expansion | Shared ACP adapter; initially Zed and one JetBrains IDE, then Xcode, Qt Creator, Neovim, Emacs, Sublime, and Devin. | Backend-specific permission/edit/cancel results; versioned client configs; no unsupported capability calls. | +| E — Full native interfaces | Shared IntelliJ plugin including separate Android Studio qualification; Visual Studio host. | Native context/diff integration, protected credentials, lifecycle reliability, usable shared React interface. | +| F — Additional native and scientific IDEs | Eclipse, NetBeans, JupyterLab/Notebook, then Spyder and RStudio. | Host-specific document tests; notebook cell/metadata preservation; installation route validated. | +| G — Conditional environments and media | Browser-only bridge work; additional cloud/embedded targets; shared mobile/desktop media integration. | Each restricted host has a verified supported route before inclusion in release claims. | + +Some work can proceed concurrently: VSIX compatibility checks do not need to wait for complete engine extraction, and native browser-container prototypes can run while ACP is developed. Make Zed the first ACP client because it provides a direct documented custom-agent route; use a JetBrains IDE as the second to expose client differences early. Qt Creator should be close behind for this project's C++/Qt development use cases. + +These phases describe dependency order and scope rather than calendar promises. The first prototypes should establish whether each remaining port is a small adapter, a substantial native integration, or a restricted environment. Set schedule estimates from those results. + +## 9. Verification and maintenance + +Reuse the repository's deterministic fake CLI, protocol captures, unit tests, and webview harness as the common test foundation. Add real-host adapter tests for behaviors that mocks cannot prove. Run live model checks only through an explicitly selected, budgeted smoke-test workflow; broad compatibility CI should not consume subscriptions or enable paid extras automatically. [Existing test and release workflow](https://github.com/RandyNorthrup/muse-spark-code/blob/main/README.md#development) + +Required release scenarios: + +1. Clean install, reload, update, uninstall, and missing/incompatible runtime. +2. Correct authentication, expired credentials, sign-out, and backend isolation. +3. Streaming and cancellation during a response, a pending permission, and a running command. +4. Allowed and refused edits/commands; delayed or already-settled decisions. +5. Saved versus dirty document content, edits made during a running turn, and change-conflict recovery. +6. Exactly-once changes, truthful diff state, and explicitly verified undo/revert behavior. +7. Multi-root/multi-window identity, Unicode paths, line endings, and remote workspace placement. +8. Session restore after UI reload and predictable behavior after runtime restart. +9. Unavailable optional capabilities, inaccessible key storage, and missing system helpers. +10. Shared-UI themes, localization, screen-reader/keyboard operation, attachments, and supported media routes. + +Test current stable releases and each declared minimum supported version. Include Windows/macOS/Linux and CPU architectures only where the IDE, agent backend, and native helpers actually support them. Record the exact client plugin version for community ACP integrations. Use JetBrains Plugin Verifier for declared native plugin targets in addition to functional tests. [Plugin compatibility tools](https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html) + +## 10. Packaging and release policy + +- Publish the VS Code package to Microsoft Marketplace and Open VSX, and retain an independently downloadable release VSIX. Preserve extension identity where registry ownership allows. +- Ship a versioned ACP executable/runtime with launch profiles that contain no API keys. Start with manual configuration; pursue ACP Registry inclusion after qualification. +- Package native IDE hosts separately, but build their shared engine and UI from the same tested source revision. +- Define a protocol compatibility range between host plugins and runtimes. Refuse incompatible pairings with a clear actionable error. +- Reuse a supported existing Node runtime when appropriate; provide a deliberate managed/bundled runtime route where users should not have to install Node. Verify packaging, licensing, signing, updates, and CPU support before selecting that route. +- Continue existing below-1.0 version policy. New ports do not independently authorize a 1.0 release. + +Open VSX distribution is central to reaching several compatible editors. Zed's current distribution documentation instead centers on the ACP Registry. Keep those as separate deliverables. [Open VSX registry FAQ](https://www.eclipse.org/legal/open-vsx-registry-faq/), [Zed agent distribution](https://zed.dev/docs/ai/external-agents) + +## 11. Connection to the Muse Spark Code companion app + +The same shared UI and application contracts can support the planned phone companion. Keep media capture as an optional host capability: phone camera, desktop microphone, or a future glasses source. Route captured assets to a specifically paired workspace/session. + +The reviewed backend turn contract accepts text, images, and skills; it does not establish universal video-input support. Future video/live capture therefore needs its own backend capability and processing plan. ACP's documented media content does not define a universal camera button or live-glasses interface in every editor. [Current turn contract](https://github.com/RandyNorthrup/muse-spark-code/blob/main/src/core/agent/agentBackend.ts), [ACP content types](https://agentclientprotocol.com/protocol/v1/content) + +For remote development, capture happens on the user's device while execution occurs beside the workspace. A device's localhost is not the cloud workspace's localhost. Design pairing, authenticated transport, session selection, and attachment delivery explicitly when that feature is implemented. Keep the capture feature independent of an IDE adapter so each new IDE can share the same asset pipeline. + +## 12. First implementation backlog + +1. Add this scope to the existing `PLAN.md` before implementation, following project instructions; preserve earlier decisions and completed milestones. +2. Inventory actual VS Code and Node API usage and record the supported runtime baseline. +3. Create the capability matrix and common adapter contract, including editing ownership and credential ownership. +4. Extract the webview bridge and remove incidental VS Code types from shared interfaces. +5. Add a standalone runtime entry and deterministic protocol fixtures. +6. Prove one ACP conversation and cancellation path in Zed, then one JetBrains client. +7. Prove safe file changes independently for the Muse Code and Model API backends before marking editing supported. +8. Qualify VSCodium, Cursor, Kiro, and Positron; establish Open VSX packaging alongside existing releases. +9. Add Xcode and Qt Creator profiles; expand community-client coverage with exact versions. +10. Build the IntelliJ/Android Studio and Visual Studio native hosts using the shared React bundle. + +The next engineering milestone should deliver a portable runtime/contract foundation plus a small number of verified hosts. The broader matrix defines the expansion path, and each support claim follows measured behavior in the relevant editor. diff --git a/docs/ide-compatibility/host-api.md b/docs/ide-compatibility/host-api.md new file mode 100644 index 00000000..e3754f94 --- /dev/null +++ b/docs/ide-compatibility/host-api.md @@ -0,0 +1,307 @@ +# Host API record + +What Muse Spark Code asks of its host (PLAN.md D60, M60). Generated by +`node scripts/check-host-api.mjs --write`; `npm run check:host-api` fails when it +differs from the source. Do not edit it by hand. + +## Manifest + +| Field | Value | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `engines.vscode` | `^1.99.0` | +| `engines.node` | `>=22` | +| `main` | `./dist/extension.js` | +| `browser` | none | +| `extensionKind` | `workspace` | +| `capabilities.virtualWorkspaces` | `false` | +| `capabilities.untrustedWorkspaces` | `limited` | +| `enabledApiProposals` | none | +| `activationEvents` | `onWebviewPanel:museSpark.chatPanel` | +| `contributes` | `commands` (24), `configuration` (2), `keybindings` (7), `menus` (2), `views` (1), `viewsContainers` (1), `walkthroughs` (1) | + +## Build targets + +| Bundle | esbuild target | +| ------------ | -------------- | +| `host_node` | `node20.18` | +| `agent_node` | `node22` | +| `browser` | `chrome128` | + +## Files that import `vscode` + +The VS Code adapter: 13 files. Everything else reaches VS Code only through them. + +| File | VS Code APIs used | +| -------------------------------------- | ----------------- | +| `src/extension.ts` | 144 | +| `src/host/cliFeatures.ts` | 26 | +| `src/host/memoryFeatures.ts` | 17 | +| `src/host/mention/mentionQuickPick.ts` | 10 | +| `src/host/paid/paidHost.ts` | 11 | +| `src/host/popups.ts` | 2 | +| `src/host/quickPick.ts` | 5 | +| `src/host/views/ChatViewProvider.ts` | 11 | +| `src/host/views/chatPanel.ts` | 11 | +| `src/host/views/surfaceRegistry.ts` | 1 | +| `src/host/views/webviewSetup.ts` | 11 | +| `src/host/voice/dictationHost.ts` | 5 | +| `src/host/worktreeFeatures.ts` | 16 | + +## Portable modules + +These never reach `vscode` through their imports, type-only ones included; the gate fails if one does: + +- everything under `src/core/` +- everything under `src/shared/` +- everything under `src/webview/` +- everything under `src/acp/` +- everything under `src/runtime/` +- `src/host/auth/authService.ts` +- `src/host/auth/credentialStore.ts` +- `src/host/backend/fileSessionStore.ts` +- `src/host/backend/modelApiBackendManager.ts` +- `src/host/backend/modelApiEntry.ts` +- `src/host/backend/museCodeBackendManager.ts` +- `src/host/backend/toolIo.ts` +- `src/host/conversation/conversationController.ts` +- `src/host/ide/ideMcpServer.ts` + +## VS Code API used at run time (201) + +Functions, variables, classes, enums and members declared in `@types/vscode`; the members of a VS Code interface the code implements (a provider, an options object); and the members of a VS Code object handed to code that takes it by shape. + +| API | Files | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Clipboard.writeText` | `src/extension.ts` | +| `ConfigurationChangeEvent.affectsConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `ConfigurationTarget.Global` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `Diagnostic.message` | `src/extension.ts` | +| `Diagnostic.range` | `src/extension.ts` | +| `Diagnostic.severity` | `src/extension.ts` | +| `Diagnostic.source` | `src/extension.ts` | +| `Disposable.dispose` | `src/host/views/ChatViewProvider.ts`, `src/host/views/chatPanel.ts`, `src/host/views/surfaceRegistry.ts`, `src/host/views/webviewSetup.ts` | +| `Extension.extensionKind` | `src/host/voice/dictationHost.ts` | +| `Extension.packageJSON` | `src/extension.ts` | +| `ExtensionContext.extension` | `src/extension.ts` | +| `ExtensionContext.extensionPath` | `src/extension.ts` | +| `ExtensionContext.extensionUri` | `src/extension.ts` | +| `ExtensionContext.globalState` | `src/extension.ts` | +| `ExtensionContext.globalStorageUri` | `src/extension.ts` | +| `ExtensionContext.secrets` | `src/extension.ts` | +| `ExtensionContext.storageUri` | `src/extension.ts` | +| `ExtensionContext.subscriptions` | `src/extension.ts` | +| `ExtensionContext.subscriptions.dispose` | `src/extension.ts` | +| `ExtensionContext.workspaceState` | `src/extension.ts` | +| `ExtensionKind.UI` | `src/host/voice/dictationHost.ts` | +| `FileStat.size` | `src/extension.ts` | +| `FileSystem.delete` | `src/extension.ts`, `src/host/memoryFeatures.ts` | +| `FileSystem.delete(options.useTrash)` | `src/extension.ts`, `src/host/memoryFeatures.ts` | +| `FileSystem.readFile` | `src/extension.ts` | +| `FileSystem.stat` | `src/extension.ts` | +| `FileSystem.writeFile` | `src/extension.ts`, `src/host/cliFeatures.ts` | +| `FileSystemError` | `src/extension.ts` | +| `FileSystemError.code` | `src/extension.ts` | +| `FileSystemWatcher.onDidChange` | `src/extension.ts` | +| `FileSystemWatcher.onDidCreate` | `src/extension.ts` | +| `FileSystemWatcher.onDidDelete` | `src/extension.ts` | +| `InputBoxOptions.ignoreFocusOut` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.password` | `src/extension.ts` | +| `InputBoxOptions.placeHolder` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.title` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.validateInput` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `LogOutputChannel.error` | `src/extension.ts` | +| `LogOutputChannel.info` | `src/extension.ts` | +| `LogOutputChannel.trace` | `src/extension.ts` | +| `LogOutputChannel.warn` | `src/extension.ts` | +| `Memento.get` | `src/extension.ts` | +| `Memento.update` | `src/extension.ts` | +| `MessageItem.isCloseAffordance` | `src/host/paid/paidHost.ts` | +| `MessageItem.title` | `src/host/paid/paidHost.ts` | +| `MessageOptions.detail` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | +| `MessageOptions.modal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | +| `OpenDialogOptions.canSelectMany` | `src/extension.ts` | +| `OpenDialogOptions.openLabel` | `src/extension.ts` | +| `OutputChannel.show` | `src/extension.ts` | +| `Position` | `src/extension.ts` | +| `Position.character` | `src/extension.ts` | +| `Position.line` | `src/extension.ts` | +| `QuickInput.dispose` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.hide` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.onDidHide` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.show` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.items` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.matchOnDescription` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.onDidAccept` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.onDidChangeValue` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.placeholder` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.selectedItems` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPickItem.label` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.canPickMany` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.ignoreFocusOut` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.matchOnDescription` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `QuickPickOptions.matchOnDetail` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts` | +| `QuickPickOptions.placeHolder` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `QuickPickOptions.title` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `Range` | `src/extension.ts` | +| `Range.contains` | `src/extension.ts` | +| `Range.end` | `src/extension.ts` | +| `Range.intersection` | `src/extension.ts` | +| `Range.isEmpty` | `src/extension.ts` | +| `Range.isEqual` | `src/extension.ts` | +| `Range.isSingleLine` | `src/extension.ts` | +| `Range.start` | `src/extension.ts` | +| `Range.union` | `src/extension.ts` | +| `Range.with` | `src/extension.ts` | +| `RelativePattern` | `src/extension.ts` | +| `SaveDialogOptions.defaultUri` | `src/host/cliFeatures.ts` | +| `SaveDialogOptions.filters` | `src/host/cliFeatures.ts` | +| `SecretStorage.delete` | `src/extension.ts` | +| `SecretStorage.get` | `src/extension.ts` | +| `SecretStorage.store` | `src/extension.ts` | +| `Selection` | `src/extension.ts` | +| `Selection.active` | `src/extension.ts` | +| `Terminal.sendText` | `src/extension.ts` | +| `Terminal.show` | `src/extension.ts` | +| `TerminalOptions.cwd` | `src/extension.ts` | +| `TerminalOptions.env` | `src/extension.ts` | +| `TerminalOptions.name` | `src/extension.ts` | +| `TerminalOptions.shellPath` | `src/extension.ts` | +| `TextDocument.getText` | `src/extension.ts` | +| `TextDocument.isDirty` | `src/extension.ts` | +| `TextDocument.lineAt` | `src/extension.ts` | +| `TextDocument.lineCount` | `src/extension.ts` | +| `TextDocument.uri` | `src/extension.ts` | +| `TextDocumentContentProvider.provideTextDocumentContent` | `src/extension.ts` | +| `TextDocumentShowOptions.preview` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `TextEditor.document` | `src/extension.ts` | +| `TextEditor.edit` | `src/extension.ts` | +| `TextEditor.revealRange` | `src/extension.ts` | +| `TextEditor.selection` | `src/extension.ts` | +| `TextEditorEdit.insert` | `src/extension.ts` | +| `TextEditorEdit.replace` | `src/extension.ts` | +| `TextEditorRevealType.InCenter` | `src/extension.ts` | +| `TextLine.range` | `src/extension.ts` | +| `Uri.authority` | `src/extension.ts` | +| `Uri.file` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `Uri.fragment` | `src/extension.ts` | +| `Uri.from` | `src/extension.ts` | +| `Uri.from(components.path)` | `src/extension.ts` | +| `Uri.from(components.query)` | `src/extension.ts` | +| `Uri.from(components.scheme)` | `src/extension.ts` | +| `Uri.fsPath` | `src/extension.ts`, `src/host/cliFeatures.ts` | +| `Uri.joinPath` | `src/extension.ts`, `src/host/views/webviewSetup.ts` | +| `Uri.parse` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `Uri.path` | `src/extension.ts` | +| `Uri.query` | `src/extension.ts` | +| `Uri.scheme` | `src/extension.ts`, `src/host/cliFeatures.ts` | +| `Uri.toString` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/views/webviewSetup.ts` | +| `ViewBadge.tooltip` | `src/host/views/ChatViewProvider.ts` | +| `ViewBadge.value` | `src/host/views/ChatViewProvider.ts` | +| `ViewColumn.Beside` | `src/host/views/chatPanel.ts` | +| `Webview.asWebviewUri` | `src/host/views/webviewSetup.ts` | +| `Webview.cspSource` | `src/host/views/webviewSetup.ts` | +| `Webview.html` | `src/host/views/webviewSetup.ts` | +| `Webview.onDidReceiveMessage` | `src/host/views/webviewSetup.ts` | +| `Webview.options` | `src/host/views/webviewSetup.ts` | +| `Webview.postMessage` | `src/host/views/webviewSetup.ts` | +| `WebviewOptions.enableScripts` | `src/host/views/webviewSetup.ts` | +| `WebviewOptions.localResourceRoots` | `src/host/views/webviewSetup.ts` | +| `WebviewPanel.active` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.onDidChangeViewState` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.onDidDispose` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.reveal` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.title` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.webview` | `src/host/views/chatPanel.ts` | +| `WebviewPanelOnDidChangeViewStateEvent.webviewPanel` | `src/host/views/chatPanel.ts` | +| `WebviewPanelOptions.retainContextWhenHidden` | `src/host/views/chatPanel.ts` | +| `WebviewPanelSerializer.deserializeWebviewPanel` | `src/extension.ts` | +| `WebviewView.badge` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.description` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.onDidChangeVisibility` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.onDidDispose` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.show` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.visible` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.webview` | `src/host/views/ChatViewProvider.ts` | +| `WebviewViewProvider.resolveWebviewView` | `src/host/views/ChatViewProvider.ts` | +| `WindowState.focused` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `WorkspaceConfiguration.get` | `src/extension.ts` | +| `WorkspaceConfiguration.update` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `WorkspaceFolder.uri` | `src/extension.ts` | +| `commands.executeCommand` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `commands.registerCommand` | `src/extension.ts` | +| `env.appName` | `src/host/voice/dictationHost.ts` | +| `env.clipboard` | `src/extension.ts` | +| `env.language` | `src/extension.ts` | +| `env.openExternal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `env.remoteName` | `src/extension.ts`, `src/host/voice/dictationHost.ts` | +| `extensions.getExtension` | `src/host/voice/dictationHost.ts` | +| `languages.getDiagnostics` | `src/extension.ts` | +| `version` | `src/extension.ts` | +| `window.activeTextEditor` | `src/extension.ts` | +| `window.createOutputChannel` | `src/extension.ts` | +| `window.createOutputChannel(options.log)` | `src/extension.ts` | +| `window.createQuickPick` | `src/extension.ts` | +| `window.createTerminal` | `src/extension.ts` | +| `window.createWebviewPanel` | `src/host/views/chatPanel.ts` | +| `window.onDidChangeActiveTextEditor` | `src/extension.ts` | +| `window.onDidChangeTextEditorSelection` | `src/extension.ts` | +| `window.onDidChangeWindowState` | `src/extension.ts` | +| `window.registerWebviewPanelSerializer` | `src/extension.ts` | +| `window.registerWebviewViewProvider` | `src/extension.ts` | +| `window.registerWebviewViewProvider(options.webviewOptions)` | `src/extension.ts` | +| `window.registerWebviewViewProvider(options.webviewOptions.retainContextWhenHidden)` | `src/extension.ts` | +| `window.showErrorMessage` | `src/extension.ts`, `src/host/popups.ts` | +| `window.showInformationMessage` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `window.showInputBox` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `window.showOpenDialog` | `src/extension.ts` | +| `window.showQuickPick` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `window.showSaveDialog` | `src/host/cliFeatures.ts` | +| `window.showTextDocument` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `window.showWarningMessage` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/popups.ts`, `src/host/worktreeFeatures.ts` | +| `window.state` | `src/host/paid/paidHost.ts` | +| `workspace.asRelativePath` | `src/extension.ts` | +| `workspace.createFileSystemWatcher` | `src/extension.ts` | +| `workspace.findFiles` | `src/extension.ts` | +| `workspace.fs` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `workspace.getConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `workspace.getWorkspaceFolder` | `src/extension.ts` | +| `workspace.isTrusted` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `workspace.onDidChangeConfiguration` | `src/extension.ts` | +| `workspace.onDidGrantWorkspaceTrust` | `src/extension.ts` | +| `workspace.openTextDocument` | `src/extension.ts` | +| `workspace.registerTextDocumentContentProvider` | `src/extension.ts` | +| `workspace.saveAll` | `src/extension.ts` | +| `workspace.textDocuments` | `src/extension.ts` | +| `workspace.workspaceFolders` | `src/extension.ts` | + +## Node built-ins the host imports (17) + +| Module | Files | +| --------------------- | ----- | +| `node:buffer` | 17 | +| `node:child_process` | 8 | +| `node:crypto` | 15 | +| `node:fs` | 13 | +| `node:fs/promises` | 16 | +| `node:http` | 1 | +| `node:module` | 1 | +| `node:net` | 1 | +| `node:os` | 4 | +| `node:path` | 45 | +| `node:process` | 1 | +| `node:stream` | 5 | +| `node:string_decoder` | 1 | +| `node:url` | 2 | +| `node:util` | 3 | +| `node:vm` | 1 | +| `node:worker_threads` | 2 | + +## The webview's host + +| Call | Files | +| ------------------ | --------------------------- | +| `acquireVsCodeApi` | `src/webview/hostBridge.ts` | + +Theme variables the styles read (57), from `src/webview/styles.css`: + +`--vscode-badge-background`, `--vscode-badge-foreground`, `--vscode-button-background`, `--vscode-button-border`, `--vscode-button-foreground`, `--vscode-button-hoverBackground`, `--vscode-button-secondaryBackground`, `--vscode-button-secondaryForeground`, `--vscode-button-secondaryHoverBackground`, `--vscode-charts-red`, `--vscode-checkbox-border`, `--vscode-debugTokenExpression-number`, `--vscode-debugTokenExpression-string`, `--vscode-descriptionForeground`, `--vscode-diffEditor-insertedLineBackground`, `--vscode-diffEditor-removedLineBackground`, `--vscode-disabledForeground`, `--vscode-editor-background`, `--vscode-editor-font-family`, `--vscode-editorCursor-foreground`, `--vscode-editorWarning-foreground`, `--vscode-editorWidget-background`, `--vscode-editorWidget-border`, `--vscode-editorWidget-foreground`, `--vscode-errorForeground`, `--vscode-focusBorder`, `--vscode-font-family`, `--vscode-font-size`, `--vscode-foreground`, `--vscode-input-background`, `--vscode-input-border`, `--vscode-input-foreground`, `--vscode-input-placeholderForeground`, `--vscode-inputValidation-errorBackground`, `--vscode-inputValidation-errorBorder`, `--vscode-inputValidation-warningBackground`, `--vscode-inputValidation-warningForeground`, `--vscode-list-activeSelectionBackground`, `--vscode-list-activeSelectionForeground`, `--vscode-list-hoverBackground`, `--vscode-menu-background`, `--vscode-menu-border`, `--vscode-menu-foreground`, `--vscode-menu-selectionBackground`, `--vscode-menu-selectionForeground`, `--vscode-panel-border`, `--vscode-progressBar-background`, `--vscode-sideBar-background`, `--vscode-symbolIcon-functionForeground`, `--vscode-symbolIcon-keywordForeground`, `--vscode-testing-iconFailed`, `--vscode-testing-iconPassed`, `--vscode-textCodeBlock-background`, `--vscode-textLink-foreground`, `--vscode-toolbar-hoverBackground`, `--vscode-widget-border`, `--vscode-widget-shadow` diff --git a/docs/ide-compatibility/hosts.md b/docs/ide-compatibility/hosts.md new file mode 100644 index 00000000..da6c31b9 --- /dev/null +++ b/docs/ide-compatibility/hosts.md @@ -0,0 +1,102 @@ +# Editors: route and status + +Every editor Muse Spark Code aims at (PLAN.md D60, D62), the route it takes, +and how far it has got. **Status** moves Planned → Prototype → Preview → +Supported, and only on recorded evidence: an install is the first step, +not the claim. **Route**: _VSIX_ (the VS Code extension as it is), _ACP_ +(the `muse-spark-code-acp` agent in the editor's own chat), _Native_ (a +plugin that embeds the Muse panel), _External_ (a terminal or adjacent +window with a limited link to the editor). + +Where it can be tested: this project's cloud container reaches npm, PyPI, +Maven Central, Gradle, NuGet, Ubuntu's archive and download.eclipse.org, +and not JetBrains, Microsoft's VS Code downloads, Open VSX, Zed's site or +neovim.io (2026-09-26). An editor the container cannot install is +qualified in CI or on the owner's machines. + +The ACP agent itself is built (M63a) and certified against the ACP SDK's +own client, over stdio and in process, with the fake backends +(`docs/certification/m63.md`). Zed, Emacs with agent-shell, Neovim with +CodeCompanion and JupyterLab with Jupyter AI have run it; every other ACP +row stays Planned until its editor has. + +CI keeps these rows honest (`test/hosts/`): the Hosts workflow runs +VSCodium and code-server (the 1.99 floor and the latest), Eclipse Theia, +JupyterLab, Emacs and Neovim on every pull request that touches the +product and every Monday; the Forks workflow runs the latest Cursor, +Devin Desktop, Kiro and Positron every Monday. Zed and the editors that +need macOS, Windows or a JetBrains download are checked by hand. + +VSCodium's integration runner activates the development extension from this +checkout. Fork runners separately verify VSIX installation and listing, then +run that development-extension integration suite. These are distinct proofs; +code-server and Theia's packaged browser checks keep their recorded scope. + +## The most used + +| Editor | Route | Milestone | Status | Evidence and notes | +| -------------------------------------------------- | --------------------------------------------- | --------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| VS Code (desktop, Remote, WSL) | VSIX | — | Supported | The reference client, on the Marketplace since 0.1.0 | +| Visual Studio (Windows) | Native (VSSDK, WebView2) | M64 | Planned | Needs Windows to build and test | +| IntelliJ IDEA, PyCharm, WebStorm, GoLand, PhpStorm | ACP (AI Assistant), then Native (JCEF) | M63, M64 | Planned | JetBrains downloads are blocked in the container | +| CLion, RustRover, RubyMine, DataGrip | ACP (AI Assistant), then Native (JCEF) | M63, M64 | Planned | As above | +| Rider | ACP (AI Assistant), then Native (JCEF) | M63, M64 | Planned | Deeper C# features would need its ReSharper backend | +| Android Studio | Native (the IntelliJ plugin, built for it) | M64 | Planned | ACP through AI Assistant not established there | +| Cursor | VSIX (Open VSX) | M62 | Preview | 2026-09-26, `forks.yml`: Cursor 3.22.7 (VS Code 1.128.0, Linux AppImage) installs the `.vsix` with its CLI and passes the integration tests (9); weekly on the latest | +| Windsurf / Devin Desktop | ACP (documented custom agents), VSIX to check | M62, M63 | Preview | 2026-09-26, `forks.yml`: Devin Desktop 3.10.35 (VS Code 1.126.0, Linux) installs the `.vsix` and passes the integration tests (9); weekly on the latest. The ACP route is plan-dependent there and untried | +| Vim | External (terminal), then a plugin | M66 | Planned | | +| Neovim | ACP (CodeCompanion first) | M63 | Preview | See CodeCompanion under the ACP agent below; in CI (`hosts.yml`) | +| Jupyter (JupyterLab 4, Notebook 7) | ACP (Jupyter AI 3), native later | M63, M65 | Preview | 2026-09-26: JupyterLab 4.6.3 with Jupyter AI 3.2.0 runs the agent as a chat persona: model, mode and effort pickers, a reply, a command allowed and one rejected (fake CLI). Its notebook tools (an HTTP MCP server) reach Muse Code through the agent since M63c | +| Sublime Text | ACP (`sublime-acp`) | M63 | Planned | A community package | +| Eclipse IDE | Native (SWT Browser) | M65 | Planned | Installable in the container from download.eclipse.org | +| Xcode 27 | ACP (Intelligence settings) | M63 | Planned | Needs macOS | +| Xcode 26.3 | External (Xcode's MCP tools) | M66 | Planned | | +| Zed | ACP (custom agent, then the ACP Registry) | M63 | Preview | See Zed under the ACP agent below; the registry wants an npm package (Q65) | +| Notepad++ | External | M66 | Planned | Windows only | + +## The VS Code family + +| Editor | Route | Milestone | Status | Evidence and notes | +| --------------------------------- | -------------------------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| VSCodium | VSIX (Open VSX) | M62 | Preview | 2026-09-26: the integration tests pass in 1.99.3 and 1.135 (9 each), using the development extension; Open VSX from the next tag | +| Kiro IDE | VSIX (Open VSX) | M62 | Preview | 2026-09-26, `forks.yml`: Kiro 1.1.70 (VS Code 1.131.0, Linux) installs the `.vsix` and passes the integration tests (9); weekly on the latest | +| Positron | VSIX (Open VSX) | M62 | Preview | 2026-09-26, `forks.yml`: Positron 2026.09.1 (VS Code 1.130.0, Linux `.deb`) installs the `.vsix` and passes the integration tests (9); weekly on the latest | +| Eclipse Theia IDE | VSIX | M62 | Preview | 2026-09-26: Theia 1.75 (browser, built from npm; it claims VS Code API 1.134) runs the panel, a conversation and an approval (fake CLI). Its sidebar stays blank until the extension starts (Ctrl+Esc or any Muse Spark command): Theia fires no `onView:` for a webview view | +| code-server | VSIX, in the server's host | M62 | Preview | 2026-09-26: 4.99.4 (VS Code 1.99.3, Node 20.18.3) installs the `.vsix`, and the panel runs a conversation and an approval in the browser (fake CLI) | +| GitHub Codespaces | VSIX, in the remote host | M62 | Planned | | +| Eclipse Che, OpenShift Dev Spaces | VSIX with a Code-OSS editor | M62 | Planned | | +| Firebase Studio | VSIX (Open VSX) | M62 | Planned | | +| Google Antigravity | VSIX, if it installs extensions | M62 | Planned | Not established that it takes arbitrary extensions | +| vscode.dev, github.dev | A browser entry and remote agent | M66 | Planned | The extension has no `browser` entry today | + +## Through the ACP agent + +| Editor | Client | Milestone | Status | Evidence and notes | +| ----------------------- | --------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Zed | Built in | M63 | Preview | 2026-09-26: Zed 1.20.2 (Linux, software rendering): a Muse Spark thread from the External Agents menu with its model and effort selectors, a streamed reply, a command allowed and one rejected from its permission card (fake CLI); the ACP Registry waits on npm (Q65) | +| JetBrains IDEs | AI Assistant | M63 | Planned | WSL not supported by JetBrains' ACP | +| Xcode 27 | Built in | M63 | Planned | | +| Qt Creator | The ACP Client extension | M63 | Planned | | +| Neovim | CodeCompanion | M63 | Preview | 2026-09-26: Neovim 0.11.4 with CodeCompanion v19.25.0: a streamed reply, a command accepted and one rejected from its approval prompt (fake CLI); setup in `docs/acp.md` | +| Emacs | agent-shell | M63 | Preview | 2026-09-26: Emacs 29.3 with agent-shell 0.79.2 (acp.el 0.15.2, shell-maker 0.97.3): modes, model and effort, a streamed reply, a tool call allowed and one rejected (fake CLI); setup in `docs/acp.md` | +| Sublime Text | sublime-acp | M63 | Planned | | +| Windsurf, Devin Desktop | Custom agents | M63 | Planned | | +| Kate | Its ACP work, once released | M66 | Planned | Still an open merge request when reviewed | + +## Native and scientific + +| Editor | Route | Milestone | Status | +| ------------------------- | ----------------------------------- | --------- | ------- | +| Apache NetBeans | Native (HTML UI) | M65 | Planned | +| Spyder | Native (Qt), Conda distribution | M65 | Planned | +| RStudio (Desktop, Server) | Native (an R addin with a web view) | M65 | Planned | +| MATLAB | External (`uihtml` app) | M66 | Planned | + +## Constrained and watched + +| Editor | Starting point | Milestone | Status | +| -------------------------------------------- | ------------------------------------------ | --------- | ------- | +| Replit | The agent in the project shell | M66 | Planned | +| StackBlitz, Codeflow | A runtime experiment | M66 | Planned | +| CodeSandbox, Ona | Remote-editor attachment | M66 | Planned | +| Arduino IDE 2, Code::Blocks, CodeLite, Geany | External tool, or a native plugin if asked | M66 | Planned | diff --git a/knip.jsonc b/knip.jsonc index 440d93f1..56baea3e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -19,6 +19,7 @@ "src/host/backend/modelApiEntry.ts", "src/host/backend/searchWorker.ts", "src/webview/main.tsx", + "src/runtime/main.ts", "test/unit/**/*.test.{ts,tsx}", "test/e2e/**/*.test.ts", "test/e2e/fake-muse/serve.mjs", @@ -33,6 +34,7 @@ "test/unit/helpers/fakeMcpPrebindParent.mjs", "test/unit/helpers/fakeMcpStartMarker.mjs", "test/integration/**/*.test.ts", + "test/hosts/*.mjs", "scripts/**/*.mjs", ".vscode-test.mjs" ], diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index d299c186..bbbc95cf 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -953,9 +953,36 @@ "editCreatedRemovedPath": "{path}: Přesunuto do koše (vytvořil ho Muse).", "modelApiNeedsFolder": "Nejdříve otevřete složku; back-end Model API pracuje v rámci pracovního prostoru.", "modelApiBundleUnavailable": "Back-end Model API se nepodařilo načíst; přeinstalujte rozšíření a znovu načtěte okno. Podrobnosti jsou v protokolu.", + "acpModelApiBundleUnavailable": "Back-end Model API se nepodařilo načíst; přeinstalujte muse-spark-code-acp a restartujte agenta. Podrobnosti jsou v protokolu agenta.", "cliNotFound": "Muse Code není nainstalován v žádném známém umístění.", "cliPathNotAbsolute": "museSpark.museBinaryPath musí být absolutní cesta.", "cliSearched": "Prohledáno: {paths}", + "acpAuthMuseCodeName": "Přihlásit se k Muse Code", + "acpAuthMuseCodeDetail": "Spustí vlastní přihlášení Muse Code v terminálu. Konverzace platí vaše předplatné Muse.", + "acpAuthKeyName": "Uložit klíč Meta Model API", + "acpAuthKeyDetail": "Přečte váš klíč v terminálu a uloží ho do úložiště přihlašovacích údajů tohoto počítače. Konverzace se účtují na klíč.", + "acpSignInByHand": "Spusťte v terminálu „{command}“ a zkuste to znovu.", + "acpMuseCodeSignedOut": "Muse Code není přihlášen; přihlaste se a zkuste to znovu.", + "acpNoStoredKey": "Není uložen žádný klíč Meta Model API; uložte ho a zkuste to znovu.", + "acpKeyPrompt": "Klíč Meta Model API (při psaní se nezobrazuje): ", + "acpKeyStored": "Klíč je uložen: {store}.", + "acpKeyNotStored": "Nebyl zadán žádný klíč, nic se neuložilo.", + "acpKeyPresent": "Klíč Meta Model API je uložen: {store}.", + "acpKeyAbsent": "Není uložen žádný klíč Meta Model API.", + "acpKeyCleared": "Klíč Meta Model API byl odebrán z úložiště přihlašovacích údajů tohoto počítače.", + "acpStoreUnavailable": "Úložiště přihlašovacích údajů tohoto počítače nelze použít ({reason}). V Linuxu agent potřebuje spuštěnou a odemčenou službu Secret Service, například GNOME Keyring nebo KWallet.", + "acpStoreNames": { + "windows": "Správce přihlašovacích údajů Windows", + "macos": "Klíčenka macOS", + "linux": "klíčenka služby Secret Service" + }, + "acpNoModels": "Backend nenabízí žádný model, který by tento agent mohl použít.", + "acpPromptBusy": "V této relaci už běží jiný prompt.", + "acpQuestionFormMessage": "Muse má na vás otázku.", + "acpQuestionAsked": "Muse má otázku; tento editor ji neumí zobrazit jako formulář, odpovězte proto v další zprávě:", + "acpUnknownArgument": "Neznámý argument: {argument}", + "acpPaidNeedsModelApi": "{argument} vyžaduje --backend modelApi: placené funkce se účtují na klíč Model API.", + "acpUsage": "Použití:\n {command} [volby] Poskytuje Agent Client Protocol přes stdin a stdout\n {command} [volby] login Přihlásí se k Muse Code v tomto terminálu\n {command} auth set|status|clear Uloží, zkontroluje nebo odebere klíč Meta Model API\nVolby:\n --backend museCode|modelApi Kdo platí: Muse Code (výchozí) nebo klíč Model API\n --trust-workspace Načte pravidla, dovednosti a paměť složky\n --muse-binary Rozhraní CLI Muse Code, které se spustí\n --shell-sandbox auto|muse|off Izolované prostředí shellu Muse Code\n --allow-dangerously-skip-permissions Nabídne režim „Obejít oprávnění“\n --allow-contributor-models Zobrazí modely úrovně contributor (Meta může trénovat na jejich obsahu)\n --web-search Nabídne placené hledání na webu (back-end Model API; nejprve se zeptá na cenu)\n --image-generation Nabídne placené vytváření obrázků (back-end Model API; nejprve se zeptá na cenu)\n --verbose Zapisuje každý detail do stderr\n --help, --version", "exportSessionLine": "Relace: `{id}`", "exportBackendLine": "Back-end: {backend}", "exportModelLine": "Model: {model}", @@ -1196,5 +1223,8 @@ "networkProxyCredentials": "Proxy server požádal o přihlašovací údaje a ty, které dostal, nepřijal. Zkontrolujte http.proxy a http.proxyAuthorization nebo údaje, o které vás požádal VS Code.", "networkProxyRefused": "Proxy server odmítl připojení (HTTP {status}). Zkontrolujte, že povoluje api.meta.ai.", "networkUnreachable": "Server Meta není dostupný. Zkontrolujte připojení k síti, a pokud používáte proxy server, také http.proxy a http.proxySupport.", + "acpNetworkUntrustedCertificate": "Certifikátu serveru se nedůvěřuje. Pokud vaše síť kontroluje HTTPS, uveďte soubor jejího kořenového certifikátu v NODE_EXTRA_CA_CERTS v prostředí agenta, nebo tam přidejte --use-system-ca do NODE_OPTIONS (Node 22.15 nebo novější), aby agent důvěřoval úložišti certifikátů operačního systému, a pak agenta restartujte.", + "acpNetworkProxyCredentials": "Proxy server požádal o přihlašovací údaje a ty, které dostal, nepřijal. Zkontrolujte uživatelské jméno a heslo v adrese proxy serveru v HTTPS_PROXY (http://user:password@host:port) v prostředí agenta a pak agenta restartujte.", + "acpNetworkUnreachable": "Server Meta není dostupný. Zkontrolujte připojení k síti. Pokud používáte proxy server, nastavte v prostředí agenta HTTPS_PROXY a NODE_USE_ENV_PROXY=1 (Node 22.21 nebo novější, případně 24) a agenta restartujte: bez NODE_USE_ENV_PROXY agent proxy server nepoužívá.", "approvalModeCeiling": "Konfigurace Muse Code (její výchozí profil oprávnění nebo zásada, kterou spravuje váš správce) tento režim oprávnění nepovoluje. Zvolte přísnější režim, například Ruční, a odešlete zprávu znovu." } diff --git a/l10n/ui.de.json b/l10n/ui.de.json index 15d6b898..191757f0 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -909,9 +909,36 @@ "editCreatedRemovedPath": "{path}: In den Papierkorb verschoben (von Muse erstellt).", "modelApiNeedsFolder": "Öffnen Sie zuerst einen Ordner; das Model-API-Backend arbeitet innerhalb eines Arbeitsbereichs.", "modelApiBundleUnavailable": "Das Model-API-Backend konnte nicht geladen werden; installieren Sie die Erweiterung neu und laden Sie das Fenster neu. Details stehen im Protokoll.", + "acpModelApiBundleUnavailable": "Das Model-API-Backend konnte nicht geladen werden; installieren Sie muse-spark-code-acp neu und starten Sie den Agenten neu. Details stehen im Protokoll des Agenten.", "cliNotFound": "Muse Code ist an keinem bekannten Speicherort installiert.", "cliPathNotAbsolute": "museSpark.museBinaryPath muss ein absoluter Pfad sein.", "cliSearched": "Durchsucht: {paths}", + "acpAuthMuseCodeName": "Bei Muse Code anmelden", + "acpAuthMuseCodeDetail": "Startet die Anmeldung von Muse Code in einem Terminal. Die Unterhaltungen bezahlt Ihr Muse-Abonnement.", + "acpAuthKeyName": "Schlüssel für die Meta Model API speichern", + "acpAuthKeyDetail": "Liest Ihren Schlüssel in einem Terminal ein und bewahrt ihn im Anmeldeinformationsspeicher dieses Computers auf. Die Unterhaltungen werden über den Schlüssel abgerechnet.", + "acpSignInByHand": "Führen Sie „{command}“ in einem Terminal aus und versuchen Sie es dann erneut.", + "acpMuseCodeSignedOut": "Muse Code ist nicht angemeldet; melden Sie sich an und versuchen Sie es erneut.", + "acpNoStoredKey": "Es ist kein Schlüssel für die Meta Model API gespeichert; speichern Sie einen und versuchen Sie es erneut.", + "acpKeyPrompt": "Schlüssel für die Meta Model API (wird bei der Eingabe nicht angezeigt): ", + "acpKeyStored": "Der Schlüssel ist gespeichert: {store}.", + "acpKeyNotStored": "Es wurde kein Schlüssel eingegeben, daher wurde nichts gespeichert.", + "acpKeyPresent": "Ein Schlüssel für die Meta Model API ist gespeichert: {store}.", + "acpKeyAbsent": "Es ist kein Schlüssel für die Meta Model API gespeichert.", + "acpKeyCleared": "Der Schlüssel für die Meta Model API wurde aus dem Anmeldeinformationsspeicher dieses Computers entfernt.", + "acpStoreUnavailable": "Der Anmeldeinformationsspeicher dieses Computers kann nicht verwendet werden ({reason}). Unter Linux benötigt der Agent einen laufenden, entsperrten Secret Service wie GNOME Keyring oder KWallet.", + "acpStoreNames": { + "windows": "Windows-Anmeldeinformationsverwaltung", + "macos": "macOS-Schlüsselbund", + "linux": "Secret-Service-Schlüsselbund" + }, + "acpNoModels": "Das Backend bietet kein Modell an, das dieser Agent verwenden darf.", + "acpPromptBusy": "In dieser Sitzung läuft bereits ein Prompt.", + "acpQuestionFormMessage": "Muse hat eine Frage an Sie.", + "acpQuestionAsked": "Muse hat eine Frage; dieser Editor kann sie nicht als Formular anzeigen, antworten Sie daher in Ihrer nächsten Nachricht:", + "acpUnknownArgument": "Unbekanntes Argument: {argument}", + "acpPaidNeedsModelApi": "{argument} erfordert --backend modelApi: Kostenpflichtige Funktionen werden über einen Model-API-Schlüssel abgerechnet.", + "acpUsage": "Verwendung:\n {command} [Optionen] Das Agent Client Protocol über stdin und stdout bereitstellen\n {command} [Optionen] login In diesem Terminal bei Muse Code anmelden\n {command} auth set|status|clear Den Schlüssel für die Meta Model API speichern, prüfen oder entfernen\nOptionen:\n --backend museCode|modelApi Wer bezahlt: Muse Code (Standard) oder der Model API-Schlüssel\n --trust-workspace Regeln, Skills und Speicher des Ordners laden\n --muse-binary Die auszuführende Muse Code-CLI\n --shell-sandbox auto|muse|off Die Shell-Sandbox von Muse Code\n --allow-dangerously-skip-permissions Den Modus „Berechtigungen umgehen“ anbieten\n --allow-contributor-models Modelle der Contributor-Stufe auflisten (Meta darf mit ihren Inhalten trainieren)\n --web-search Kostenpflichtige Websuche anbieten (Model-API-Backend; der Preis wird zuerst erfragt)\n --image-generation Kostenpflichtige Bilderzeugung anbieten (Model-API-Backend; der Preis wird zuerst erfragt)\n --verbose Jedes Detail auf stderr protokollieren\n --help, --version", "exportSessionLine": "Sitzung: `{id}`", "exportBackendLine": "Backend: {backend}", "exportModelLine": "Modell: {model}", @@ -1132,5 +1159,8 @@ "networkProxyCredentials": "Der Proxy hat Anmeldeinformationen verlangt und die erhaltenen nicht akzeptiert. Prüfen Sie http.proxy und http.proxyAuthorization oder die Anmeldeinformationen, nach denen VS Code Sie gefragt hat.", "networkProxyRefused": "Der Proxy hat die Verbindung abgelehnt (HTTP {status}). Prüfen Sie, ob er api.meta.ai zulässt.", "networkUnreachable": "Der Server von Meta war nicht erreichbar. Prüfen Sie die Netzwerkverbindung sowie http.proxy und http.proxySupport, wenn Sie einen Proxy verwenden.", + "acpNetworkUntrustedCertificate": "Das Zertifikat des Servers ist nicht vertrauenswürdig. Wenn Ihr Netzwerk HTTPS prüft, geben Sie die Datei seines Stammzertifikats in NODE_EXTRA_CA_CERTS in der Umgebung des Agenten an, oder fügen Sie dort --use-system-ca zu NODE_OPTIONS hinzu (Node 22.15 oder neuer), damit der Agent dem Zertifikatspeicher des Betriebssystems vertraut, und starten Sie den Agenten dann neu.", + "acpNetworkProxyCredentials": "Der Proxy hat Anmeldeinformationen verlangt und die erhaltenen nicht akzeptiert. Prüfen Sie Benutzernamen und Kennwort in der Adresse des Proxys in HTTPS_PROXY (http://user:password@host:port) in der Umgebung des Agenten, und starten Sie den Agenten dann neu.", + "acpNetworkUnreachable": "Der Server von Meta war nicht erreichbar. Prüfen Sie die Netzwerkverbindung. Hinter einem Proxy setzen Sie HTTPS_PROXY und NODE_USE_ENV_PROXY=1 in der Umgebung des Agenten (Node 22.21 oder neuer, oder 24) und starten den Agenten neu: Ohne NODE_USE_ENV_PROXY verwendet der Agent den Proxy nicht.", "approvalModeCeiling": "Die Konfiguration von Muse Code (ihr Standard-Berechtigungsprofil oder eine von Ihrem Administrator verwaltete Richtlinie) lässt diesen Berechtigungsmodus nicht zu. Wählen Sie einen strengeren, etwa „Manuell“, und senden Sie erneut." } diff --git a/l10n/ui.es.json b/l10n/ui.es.json index 3e600b67..c07b699b 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -931,9 +931,36 @@ "editCreatedRemovedPath": "{path}: se movió a la papelera (lo creó Muse).", "modelApiNeedsFolder": "Abra primero una carpeta; el back-end de Model API trabaja dentro de un área de trabajo.", "modelApiBundleUnavailable": "No se pudo cargar el back-end de Model API; reinstale la extensión y vuelva a cargar la ventana. El registro tiene los detalles.", + "acpModelApiBundleUnavailable": "No se pudo cargar el back-end de Model API; reinstale muse-spark-code-acp y reinicie el agente. El registro del agente tiene los detalles.", "cliNotFound": "Muse Code no está instalado en ninguna ubicación conocida.", "cliPathNotAbsolute": "museSpark.museBinaryPath debe ser una ruta de acceso absoluta.", "cliSearched": "Ubicaciones buscadas: {paths}", + "acpAuthMuseCodeName": "Iniciar sesión en Muse Code", + "acpAuthMuseCodeDetail": "Ejecuta el inicio de sesión propio de Muse Code en un terminal. Su suscripción a Muse paga las conversaciones.", + "acpAuthKeyName": "Guardar una clave de Meta Model API", + "acpAuthKeyDetail": "Lee su clave en un terminal y la guarda en el almacén de credenciales de este equipo. Las conversaciones se facturan a la clave.", + "acpSignInByHand": "Ejecute «{command}» en un terminal y vuelva a intentarlo.", + "acpMuseCodeSignedOut": "Muse Code no tiene la sesión iniciada; inicie sesión y vuelva a intentarlo.", + "acpNoStoredKey": "No hay ninguna clave de Meta Model API guardada; guarde una y vuelva a intentarlo.", + "acpKeyPrompt": "Clave de Meta Model API (no se muestra al escribirla): ", + "acpKeyStored": "La clave está guardada: {store}.", + "acpKeyNotStored": "No se introdujo ninguna clave, así que no se guardó nada.", + "acpKeyPresent": "Hay una clave de Meta Model API guardada: {store}.", + "acpKeyAbsent": "No hay ninguna clave de Meta Model API guardada.", + "acpKeyCleared": "La clave de Meta Model API se quitó del almacén de credenciales de este equipo.", + "acpStoreUnavailable": "No se puede usar el almacén de credenciales de este equipo ({reason}). En Linux, el agente necesita un Secret Service en ejecución y desbloqueado, como GNOME Keyring o KWallet.", + "acpStoreNames": { + "windows": "Administrador de credenciales de Windows", + "macos": "Llavero de macOS", + "linux": "llavero de Secret Service" + }, + "acpNoModels": "El backend no ofrece ningún modelo que este agente pueda usar.", + "acpPromptBusy": "Ya hay un prompt en curso en esta sesión.", + "acpQuestionFormMessage": "Muse tiene una pregunta para usted.", + "acpQuestionAsked": "Muse tiene una pregunta; este editor no puede mostrarla como formulario, así que responda en su próximo mensaje:", + "acpUnknownArgument": "Argumento desconocido: {argument}", + "acpPaidNeedsModelApi": "{argument} requiere --backend modelApi: las funciones de pago se facturan a una clave de Model API.", + "acpUsage": "Uso:\n {command} [opciones] Servir el Agent Client Protocol por stdin y stdout\n {command} [opciones] login Iniciar sesión en Muse Code en este terminal\n {command} auth set|status|clear Guardar, comprobar o quitar la clave de Meta Model API\nOpciones:\n --backend museCode|modelApi Quién paga: Muse Code (predeterminado) o la clave de Model API\n --trust-workspace Cargar las reglas, las habilidades y la memoria de la carpeta\n --muse-binary La CLI de Muse Code que se ejecuta\n --shell-sandbox auto|muse|off El espacio aislado del shell de Muse Code\n --allow-dangerously-skip-permissions Ofrecer el modo «Omitir permisos»\n --allow-contributor-models Mostrar los modelos de nivel colaborador (Meta puede entrenar con su contenido)\n --web-search Ofrecer la búsqueda web de pago (back-end de Model API; primero se pregunta su precio)\n --image-generation Ofrecer la generación de imágenes de pago (back-end de Model API; primero se pregunta su precio)\n --verbose Registrar cada detalle en stderr\n --help, --version", "exportSessionLine": "Sesión: `{id}`", "exportBackendLine": "Back-end: {backend}", "exportModelLine": "Modelo: {model}", @@ -1164,5 +1191,8 @@ "networkProxyCredentials": "El proxy pidió credenciales y no aceptó las que recibió. Compruebe http.proxy y http.proxyAuthorization, o las credenciales que le pidió VS Code.", "networkProxyRefused": "El proxy rechazó la conexión (HTTP {status}). Compruebe que permite api.meta.ai.", "networkUnreachable": "No se pudo acceder al servidor de Meta. Compruebe la conexión de red, y http.proxy y http.proxySupport si usa un proxy.", + "acpNetworkUntrustedCertificate": "El certificado del servidor no es de confianza. Si su red inspecciona HTTPS, indique el archivo de su certificado raíz en NODE_EXTRA_CA_CERTS en el entorno del agente, o añada allí --use-system-ca a NODE_OPTIONS (Node 22.15 o posterior) para que el agente confíe en el almacén de certificados del sistema operativo, y después reinicie el agente.", + "acpNetworkProxyCredentials": "El proxy pidió credenciales y no aceptó las que recibió. Compruebe el usuario y la contraseña en la dirección del proxy en HTTPS_PROXY (http://user:password@host:port) en el entorno del agente, y después reinicie el agente.", + "acpNetworkUnreachable": "No se pudo acceder al servidor de Meta. Compruebe la conexión de red. Detrás de un proxy, defina HTTPS_PROXY y NODE_USE_ENV_PROXY=1 en el entorno del agente (Node 22.21 o posterior, o 24) y reinicie el agente: sin NODE_USE_ENV_PROXY, el agente no usa el proxy.", "approvalModeCeiling": "La configuración de Muse Code (su perfil de permisos predeterminado o una directiva que administra su administrador) no permite este modo de permisos. Elija uno más estricto, como Manual, y vuelva a enviar." } diff --git a/l10n/ui.fr.json b/l10n/ui.fr.json index bc173da1..4cd60a3e 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -931,9 +931,36 @@ "editCreatedRemovedPath": "{path} : déplacé dans la corbeille (Muse l’avait créé).", "modelApiNeedsFolder": "Ouvrez d’abord un dossier ; le back-end Model API travaille dans un espace de travail.", "modelApiBundleUnavailable": "Le back-end Model API n’a pas pu être chargé ; réinstallez l’extension et rechargez la fenêtre. Le journal contient les détails.", + "acpModelApiBundleUnavailable": "Le back-end Model API n’a pas pu être chargé ; réinstallez muse-spark-code-acp et redémarrez l’agent. Le journal de l’agent contient les détails.", "cliNotFound": "Muse Code n’est installé dans aucun emplacement connu.", "cliPathNotAbsolute": "museSpark.museBinaryPath doit être un chemin absolu.", "cliSearched": "Emplacements recherchés : {paths}", + "acpAuthMuseCodeName": "Se connecter à Muse Code", + "acpAuthMuseCodeDetail": "Lance la connexion propre à Muse Code dans un terminal. Votre abonnement Muse paie les conversations.", + "acpAuthKeyName": "Enregistrer une clé Meta Model API", + "acpAuthKeyDetail": "Lit votre clé dans un terminal et la conserve dans le magasin d’identifiants de cet ordinateur. Les conversations sont facturées à la clé.", + "acpSignInByHand": "Exécutez « {command} » dans un terminal, puis réessayez.", + "acpMuseCodeSignedOut": "Muse Code n’est pas connecté ; connectez-vous puis réessayez.", + "acpNoStoredKey": "Aucune clé Meta Model API n’est enregistrée ; enregistrez-en une puis réessayez.", + "acpKeyPrompt": "Clé Meta Model API (non affichée pendant la saisie) : ", + "acpKeyStored": "La clé est enregistrée : {store}.", + "acpKeyNotStored": "Aucune clé n’a été saisie ; rien n’a été enregistré.", + "acpKeyPresent": "Une clé Meta Model API est enregistrée : {store}.", + "acpKeyAbsent": "Aucune clé Meta Model API n’est enregistrée.", + "acpKeyCleared": "La clé Meta Model API a été supprimée du magasin d’identifiants de cet ordinateur.", + "acpStoreUnavailable": "Le magasin d’identifiants de cet ordinateur est inutilisable ({reason}). Sous Linux, l’agent a besoin d’un Secret Service lancé et déverrouillé, comme GNOME Keyring ou KWallet.", + "acpStoreNames": { + "windows": "Gestionnaire d’identification Windows", + "macos": "Trousseau macOS", + "linux": "trousseau Secret Service" + }, + "acpNoModels": "Le backend ne propose aucun modèle que cet agent puisse utiliser.", + "acpPromptBusy": "Un prompt est déjà en cours dans cette session.", + "acpQuestionFormMessage": "Muse a une question pour vous.", + "acpQuestionAsked": "Muse a une question ; cet éditeur ne peut pas l’afficher sous forme de formulaire, répondez donc dans votre prochain message :", + "acpUnknownArgument": "Argument inconnu : {argument}", + "acpPaidNeedsModelApi": "{argument} nécessite --backend modelApi : les fonctionnalités payantes sont facturées sur une clé Model API.", + "acpUsage": "Utilisation :\n {command} [options] Servir l’Agent Client Protocol sur stdin et stdout\n {command} [options] login Se connecter à Muse Code dans ce terminal\n {command} auth set|status|clear Enregistrer, vérifier ou supprimer la clé Meta Model API\nOptions :\n --backend museCode|modelApi Qui paie : Muse Code (par défaut) ou la clé Model API\n --trust-workspace Charger les règles, les compétences et la mémoire du dossier\n --muse-binary La CLI Muse Code à exécuter\n --shell-sandbox auto|muse|off Le bac à sable du shell de Muse Code\n --allow-dangerously-skip-permissions Proposer le mode « Contourner les autorisations »\n --allow-contributor-models Lister les modèles de niveau contributeur (Meta peut s’entraîner sur leur contenu)\n --web-search Proposer la recherche web payante (back-end Model API ; son prix est demandé d’abord)\n --image-generation Proposer la génération d’images payante (back-end Model API ; son prix est demandé d’abord)\n --verbose Journaliser chaque détail sur stderr\n --help, --version", "exportSessionLine": "Session : `{id}`", "exportBackendLine": "Back-end : {backend}", "exportModelLine": "Modèle : {model}", @@ -1164,5 +1191,8 @@ "networkProxyCredentials": "Le proxy a demandé des identifiants et n’a pas accepté ceux qu’il a reçus. Vérifiez http.proxy et http.proxyAuthorization, ou les identifiants que VS Code vous a demandés.", "networkProxyRefused": "Le proxy a refusé la connexion (HTTP {status}). Vérifiez qu’il autorise api.meta.ai.", "networkUnreachable": "Le serveur de Meta est injoignable. Vérifiez la connexion réseau, ainsi que http.proxy et http.proxySupport si vous utilisez un proxy.", + "acpNetworkUntrustedCertificate": "Le certificat du serveur n’est pas approuvé. Si votre réseau inspecte le trafic HTTPS, désignez le fichier de son certificat racine dans NODE_EXTRA_CA_CERTS dans l’environnement de l’agent, ou ajoutez-y --use-system-ca à NODE_OPTIONS (Node 22.15 ou ultérieur) pour que l’agent approuve le magasin de certificats du système d’exploitation, puis redémarrez l’agent.", + "acpNetworkProxyCredentials": "Le proxy a demandé des identifiants et n’a pas accepté ceux qu’il a reçus. Vérifiez le nom d’utilisateur et le mot de passe dans l’adresse du proxy dans HTTPS_PROXY (http://user:password@host:port) dans l’environnement de l’agent, puis redémarrez l’agent.", + "acpNetworkUnreachable": "Le serveur de Meta est injoignable. Vérifiez la connexion réseau. Derrière un proxy, définissez HTTPS_PROXY et NODE_USE_ENV_PROXY=1 dans l’environnement de l’agent (Node 22.21 ou ultérieur, ou 24) et redémarrez l’agent : sans NODE_USE_ENV_PROXY, l’agent n’utilise pas le proxy.", "approvalModeCeiling": "La configuration de Muse Code (son profil d’autorisations par défaut, ou une stratégie gérée par votre administrateur) n’autorise pas ce mode d’autorisation. Choisissez-en un plus strict, par exemple Manuel, puis renvoyez le message." } diff --git a/l10n/ui.hu.json b/l10n/ui.hu.json index 6e63b8e7..2314f8b5 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -909,9 +909,36 @@ "editCreatedRemovedPath": "{path}: áthelyezve a Lomtárba (a Muse hozta létre).", "modelApiNeedsFolder": "Először nyisson meg egy mappát; a Model API háttérrendszer munkaterületen belül dolgozik.", "modelApiBundleUnavailable": "A Model API háttérrendszer nem tölthető be; telepítse újra a bővítményt, és töltse újra az ablakot. A részletek a naplóban vannak.", + "acpModelApiBundleUnavailable": "A Model API háttérrendszer nem tölthető be; telepítse újra a muse-spark-code-acp csomagot, és indítsa újra az ügynököt. A részletek az ügynök naplójában vannak.", "cliNotFound": "A Muse Code egyetlen ismert helyen sincs telepítve.", "cliPathNotAbsolute": "A museSpark.museBinaryPath értékének abszolút elérési útnak kell lennie.", "cliSearched": "Átkeresett helyek: {paths}", + "acpAuthMuseCodeName": "Bejelentkezés a Muse Code-ba", + "acpAuthMuseCodeDetail": "Terminálban elindítja a Muse Code saját bejelentkezését. A beszélgetéseket az Ön Muse-előfizetése fizeti.", + "acpAuthKeyName": "Meta Model API-kulcs tárolása", + "acpAuthKeyDetail": "Terminálban beolvassa a kulcsot, és a számítógép hitelesítőadat-tárolójában őrzi. A beszélgetéseket a kulcs terhére számlázzák.", + "acpSignInByHand": "Futtassa a(z) „{command}” parancsot egy terminálban, majd próbálja újra.", + "acpMuseCodeSignedOut": "A Muse Code nincs bejelentkezve; jelentkezzen be, majd próbálja újra.", + "acpNoStoredKey": "Nincs tárolt Meta Model API-kulcs; tároljon egyet, majd próbálja újra.", + "acpKeyPrompt": "Meta Model API-kulcs (gépelés közben nem látszik): ", + "acpKeyStored": "A kulcs tárolva: {store}.", + "acpKeyNotStored": "Nem adott meg kulcsot, így semmi sem lett tárolva.", + "acpKeyPresent": "Van tárolt Meta Model API-kulcs: {store}.", + "acpKeyAbsent": "Nincs tárolt Meta Model API-kulcs.", + "acpKeyCleared": "A Meta Model API-kulcs törölve lett a számítógép hitelesítőadat-tárolójából.", + "acpStoreUnavailable": "A számítógép hitelesítőadat-tárolója nem használható ({reason}). Linuxon az ügynöknek futó, feloldott Secret Service szolgáltatásra van szüksége, például GNOME Keyringre vagy KWalletre.", + "acpStoreNames": { + "windows": "Windows Hitelesítőadat-kezelő", + "macos": "macOS Kulcskarika", + "linux": "Secret Service kulcstartó" + }, + "acpNoModels": "A háttérrendszer nem kínál olyan modellt, amelyet ez az ügynök használhat.", + "acpPromptBusy": "Ebben a munkamenetben már fut egy prompt.", + "acpQuestionFormMessage": "Muse-nak kérdése van Önhöz.", + "acpQuestionAsked": "Muse-nak kérdése van; ez a szerkesztő nem tudja űrlapként megjeleníteni, ezért a következő üzenetében válaszoljon:", + "acpUnknownArgument": "Ismeretlen argumentum: {argument}", + "acpPaidNeedsModelApi": "A(z) {argument} használatához --backend modelApi szükséges: a fizetős funkciókat egy Model API-kulcsra számlázzák.", + "acpUsage": "Használat:\n {command} [kapcsolók] Az Agent Client Protocol kiszolgálása stdin és stdout felett\n {command} [kapcsolók] login Bejelentkezés a Muse Code-ba ebben a terminálban\n {command} auth set|status|clear A Meta Model API-kulcs tárolása, ellenőrzése vagy törlése\nKapcsolók:\n --backend museCode|modelApi Ki fizet: a Muse Code (alapértelmezett) vagy a Model API-kulcs\n --trust-workspace A mappa szabályainak, képességeinek és memóriájának betöltése\n --muse-binary <útvonal> A futtatandó Muse Code parancssori eszköz\n --shell-sandbox auto|muse|off A Muse Code parancsértelmező-védőkörnyezete\n --allow-dangerously-skip-permissions A(z) „Engedélyek megkerülése” mód felkínálása\n --allow-contributor-models A közreműködői szintű modellek listázása (a Meta tanulhat a tartalmukból)\n --web-search Fizetős webes keresés felajánlása (Model API háttérrendszer; előbb az áráról kérdez)\n --image-generation Fizetős képkészítés felajánlása (Model API háttérrendszer; előbb az áráról kérdez)\n --verbose Minden részlet naplózása az stderr-re\n --help, --version", "exportSessionLine": "Munkamenet: `{id}`", "exportBackendLine": "Háttérrendszer: {backend}", "exportModelLine": "Modell: {model}", @@ -1132,5 +1159,8 @@ "networkProxyCredentials": "A proxy hitelesítő adatokat kért, és nem fogadta el a kapottakat. Ellenőrizze a http.proxy és a http.proxyAuthorization beállítást, vagy azokat a hitelesítő adatokat, amelyeket a VS Code kért Öntől.", "networkProxyRefused": "A proxy elutasította a kapcsolatot (HTTP {status}). Ellenőrizze, hogy engedélyezi-e az api.meta.ai címet.", "networkUnreachable": "A Meta kiszolgálója nem érhető el. Ellenőrizze a hálózati kapcsolatot, és ha proxyt használ, a http.proxy és a http.proxySupport beállítást.", + "acpNetworkUntrustedCertificate": "A kiszolgáló tanúsítványa nem megbízható. Ha a hálózata vizsgálja a HTTPS-forgalmat, adja meg a gyökértanúsítványa fájlját a NODE_EXTRA_CA_CERTS változóban az ügynök környezetében, vagy ugyanott adja hozzá a --use-system-ca kapcsolót a NODE_OPTIONS változóhoz (Node 22.15 vagy újabb), hogy az ügynök megbízzon az operációs rendszer tanúsítványtárolójában, majd indítsa újra az ügynököt.", + "acpNetworkProxyCredentials": "A proxy hitelesítő adatokat kért, és nem fogadta el a kapottakat. Ellenőrizze a felhasználónevet és a jelszót a proxy címében a HTTPS_PROXY változóban (http://user:password@host:port) az ügynök környezetében, majd indítsa újra az ügynököt.", + "acpNetworkUnreachable": "A Meta kiszolgálója nem érhető el. Ellenőrizze a hálózati kapcsolatot. Proxy mögött állítsa be a HTTPS_PROXY és a NODE_USE_ENV_PROXY=1 változót az ügynök környezetében (Node 22.21 vagy újabb, illetve 24), és indítsa újra az ügynököt: NODE_USE_ENV_PROXY nélkül az ügynök nem használja a proxyt.", "approvalModeCeiling": "A Muse Code konfigurációja (az alapértelmezett engedélyprofilja vagy a rendszergazda által felügyelt házirend) nem engedélyezi ezt az engedélyezési módot. Válasszon szigorúbbat, például a Kézi módot, és küldje el újra." } diff --git a/l10n/ui.it.json b/l10n/ui.it.json index a82d0f21..2a565c9d 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -931,9 +931,36 @@ "editCreatedRemovedPath": "{path}: spostato nel cestino (creato da Muse).", "modelApiNeedsFolder": "Apri prima una cartella; il back-end Model API lavora all’interno di un’area di lavoro.", "modelApiBundleUnavailable": "Impossibile caricare il back-end Model API; reinstalla l’estensione e ricarica la finestra. Il log contiene i dettagli.", + "acpModelApiBundleUnavailable": "Impossibile caricare il back-end Model API; reinstalla muse-spark-code-acp e riavvia l’agente. Il log dell’agente contiene i dettagli.", "cliNotFound": "Muse Code non è installato in nessun percorso noto.", "cliPathNotAbsolute": "museSpark.museBinaryPath deve essere un percorso assoluto.", "cliSearched": "Percorsi cercati: {paths}", + "acpAuthMuseCodeName": "Accedi a Muse Code", + "acpAuthMuseCodeDetail": "Avvia l’accesso di Muse Code in un terminale. Le conversazioni sono pagate dal tuo abbonamento Muse.", + "acpAuthKeyName": "Salva una chiave Meta Model API", + "acpAuthKeyDetail": "Legge la tua chiave in un terminale e la conserva nell’archivio delle credenziali di questo computer. Le conversazioni vengono addebitate alla chiave.", + "acpSignInByHand": "Esegui «{command}» in un terminale, poi riprova.", + "acpMuseCodeSignedOut": "Muse Code non ha effettuato l’accesso; accedi e riprova.", + "acpNoStoredKey": "Nessuna chiave Meta Model API salvata; salvane una e riprova.", + "acpKeyPrompt": "Chiave Meta Model API (non visualizzata durante la digitazione): ", + "acpKeyStored": "La chiave è salvata: {store}.", + "acpKeyNotStored": "Non è stata inserita alcuna chiave, quindi non è stato salvato nulla.", + "acpKeyPresent": "È salvata una chiave Meta Model API: {store}.", + "acpKeyAbsent": "Nessuna chiave Meta Model API salvata.", + "acpKeyCleared": "La chiave Meta Model API è stata rimossa dall’archivio delle credenziali di questo computer.", + "acpStoreUnavailable": "Impossibile usare l’archivio delle credenziali di questo computer ({reason}). Su Linux l’agente richiede un Secret Service avviato e sbloccato, come GNOME Keyring o KWallet.", + "acpStoreNames": { + "windows": "Gestione credenziali di Windows", + "macos": "Portachiavi di macOS", + "linux": "portachiavi Secret Service" + }, + "acpNoModels": "Il backend non offre alcun modello che questo agente possa usare.", + "acpPromptBusy": "In questa sessione è già in corso un prompt.", + "acpQuestionFormMessage": "Muse ha una domanda per te.", + "acpQuestionAsked": "Muse ha una domanda; questo editor non può mostrarla come modulo, quindi rispondi nel prossimo messaggio:", + "acpUnknownArgument": "Argomento sconosciuto: {argument}", + "acpPaidNeedsModelApi": "{argument} richiede --backend modelApi: le funzionalità a pagamento vengono addebitate a una chiave Model API.", + "acpUsage": "Uso:\n {command} [opzioni] Serve l’Agent Client Protocol su stdin e stdout\n {command} [opzioni] login Accedi a Muse Code in questo terminale\n {command} auth set|status|clear Salva, controlla o rimuovi la chiave Meta Model API\nOpzioni:\n --backend museCode|modelApi Chi paga: Muse Code (predefinito) o la chiave Model API\n --trust-workspace Carica regole, skill e memoria della cartella\n --muse-binary La CLI di Muse Code da eseguire\n --shell-sandbox auto|muse|off La sandbox della shell di Muse Code\n --allow-dangerously-skip-permissions Offri la modalità «Ignora autorizzazioni»\n --allow-contributor-models Elenca i modelli di livello contributor (Meta può addestrarsi sui loro contenuti)\n --web-search Offre la ricerca web a pagamento (back-end Model API; prima ne chiede il prezzo)\n --image-generation Offre la generazione di immagini a pagamento (back-end Model API; prima ne chiede il prezzo)\n --verbose Registra ogni dettaglio su stderr\n --help, --version", "exportSessionLine": "Sessione: `{id}`", "exportBackendLine": "Back-end: {backend}", "exportModelLine": "Modello: {model}", @@ -1164,5 +1191,8 @@ "networkProxyCredentials": "Il proxy ha chiesto delle credenziali e non ha accettato quelle ricevute. Controlla http.proxy e http.proxyAuthorization, oppure le credenziali che VS Code ti ha chiesto.", "networkProxyRefused": "Il proxy ha rifiutato la connessione (HTTP {status}). Verifica che consenta api.meta.ai.", "networkUnreachable": "Impossibile raggiungere il server di Meta. Controlla la connessione di rete e, se usi un proxy, http.proxy e http.proxySupport.", + "acpNetworkUntrustedCertificate": "Il certificato del server non è attendibile. Se la tua rete ispeziona il traffico HTTPS, indica il file del suo certificato radice in NODE_EXTRA_CA_CERTS nell’ambiente dell’agente, oppure aggiungi lì --use-system-ca a NODE_OPTIONS (Node 22.15 o successivo) perché l’agente consideri attendibile l’archivio certificati del sistema operativo, poi riavvia l’agente.", + "acpNetworkProxyCredentials": "Il proxy ha chiesto delle credenziali e non ha accettato quelle ricevute. Controlla nome utente e password nell’indirizzo del proxy in HTTPS_PROXY (http://user:password@host:port) nell’ambiente dell’agente, poi riavvia l’agente.", + "acpNetworkUnreachable": "Impossibile raggiungere il server di Meta. Controlla la connessione di rete. Dietro un proxy, imposta HTTPS_PROXY e NODE_USE_ENV_PROXY=1 nell’ambiente dell’agente (Node 22.21 o successivo, oppure 24) e riavvia l’agente: senza NODE_USE_ENV_PROXY l’agente non usa il proxy.", "approvalModeCeiling": "La configurazione di Muse Code (il suo profilo di autorizzazioni predefinito o un criterio gestito dal tuo amministratore) non consente questa modalità di autorizzazione. Scegline una più restrittiva, ad esempio Manuale, e invia di nuovo." } diff --git a/l10n/ui.ja.json b/l10n/ui.ja.json index be334b47..cc3bc69f 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -887,9 +887,36 @@ "editCreatedRemovedPath": "{path}: ごみ箱に移動しました (Muse が作成したファイル)。", "modelApiNeedsFolder": "まずフォルダーを開いてください。Model API バックエンドはワークスペース内で動作します。", "modelApiBundleUnavailable": "Model API バックエンドを読み込めませんでした。拡張機能を再インストールし、ウィンドウを再読み込みしてください。詳細はログにあります。", + "acpModelApiBundleUnavailable": "Model API バックエンドを読み込めませんでした。muse-spark-code-acp を再インストールし、エージェントを再起動してください。詳細はエージェントのログにあります。", "cliNotFound": "Muse Code は既知のどの場所にもインストールされていません。", "cliPathNotAbsolute": "museSpark.museBinaryPath は絶対パスである必要があります。", "cliSearched": "検索した場所: {paths}", + "acpAuthMuseCodeName": "Muse Code にサインイン", + "acpAuthMuseCodeDetail": "Muse Code 自身のサインインをターミナルで実行します。会話の料金は Muse サブスクリプションで支払われます。", + "acpAuthKeyName": "Meta Model API キーを保存", + "acpAuthKeyDetail": "ターミナルでキーを読み取り、このコンピューターの資格情報ストアに保管します。会話はこのキーに課金されます。", + "acpSignInByHand": "ターミナルで「{command}」を実行してから、もう一度お試しください。", + "acpMuseCodeSignedOut": "Muse Code にサインインしていません。サインインしてから、もう一度お試しください。", + "acpNoStoredKey": "Meta Model API キーが保存されていません。キーを保存してから、もう一度お試しください。", + "acpKeyPrompt": "Meta Model API キー (入力中は表示されません): ", + "acpKeyStored": "キーを保存しました: {store}。", + "acpKeyNotStored": "キーが入力されなかったため、何も保存しませんでした。", + "acpKeyPresent": "Meta Model API キーが保存されています: {store}。", + "acpKeyAbsent": "Meta Model API キーは保存されていません。", + "acpKeyCleared": "Meta Model API キーをこのコンピューターの資格情報ストアから削除しました。", + "acpStoreUnavailable": "このコンピューターの資格情報ストアを使用できません ({reason})。Linux では、GNOME Keyring や KWallet など、実行中でロック解除された Secret Service がエージェントに必要です。", + "acpStoreNames": { + "windows": "Windows 資格情報マネージャー", + "macos": "macOS キーチェーン", + "linux": "Secret Service キーリング" + }, + "acpNoModels": "バックエンドには、このエージェントが使用できるモデルがありません。", + "acpPromptBusy": "このセッションでは既にプロンプトを実行中です。", + "acpQuestionFormMessage": "Muse から質問があります。", + "acpQuestionAsked": "Muse から質問があります。このエディターではフォームとして表示できないため、次のメッセージで回答してください:", + "acpUnknownArgument": "不明な引数: {argument}", + "acpPaidNeedsModelApi": "{argument} には --backend modelApi が必要です。有料機能は Model API キーに請求されます。", + "acpUsage": "使い方:\n {command} [オプション] stdin と stdout で Agent Client Protocol を提供します\n {command} [オプション] login このターミナルで Muse Code にサインインします\n {command} auth set|status|clear Meta Model API キーを保存、確認、または削除します\nオプション:\n --backend museCode|modelApi 支払い元: Muse Code (既定) または Model API キー\n --trust-workspace フォルダーのルール、スキル、メモリを読み込みます\n --muse-binary <パス> 実行する Muse Code CLI\n --shell-sandbox auto|muse|off Muse Code のシェル サンドボックス\n --allow-dangerously-skip-permissions 「権限バイパス」モードを表示します\n --allow-contributor-models コントリビューター階層のモデルを表示します (Meta がその内容で学習する場合があります)\n --web-search 有料の Web 検索を提供します (Model API バックエンド、先に料金を確認します)\n --image-generation 有料の画像生成を提供します (Model API バックエンド、先に料金を確認します)\n --verbose すべての詳細を stderr に記録します\n --help, --version", "exportSessionLine": "セッション: `{id}`", "exportBackendLine": "バックエンド: {backend}", "exportModelLine": "モデル: {model}", @@ -1100,5 +1127,8 @@ "networkProxyCredentials": "プロキシが資格情報を要求し、受け取った資格情報を受け入れませんでした。http.proxy と http.proxyAuthorization、または VS Code から求められた資格情報を確認してください。", "networkProxyRefused": "プロキシが接続を拒否しました (HTTP {status})。api.meta.ai が許可されていることを確認してください。", "networkUnreachable": "Meta のサーバーに接続できませんでした。ネットワーク接続と、プロキシを使用している場合は http.proxy と http.proxySupport を確認してください。", + "acpNetworkUntrustedCertificate": "サーバーの証明書は信頼されていません。ネットワークが HTTPS を検査している場合は、エージェントの環境の NODE_EXTRA_CA_CERTS でそのルート証明書のファイルを指定するか、同じ環境の NODE_OPTIONS に --use-system-ca を追加して (Node 22.15 以降) オペレーティング システムの証明書ストアをエージェントに信頼させてから、エージェントを再起動してください。", + "acpNetworkProxyCredentials": "プロキシが資格情報を要求し、受け取った資格情報を受け入れませんでした。エージェントの環境の HTTPS_PROXY にあるプロキシのアドレス (http://user:password@host:port) のユーザー名とパスワードを確認してから、エージェントを再起動してください。", + "acpNetworkUnreachable": "Meta のサーバーに接続できませんでした。ネットワーク接続を確認してください。プロキシを使用している場合は、エージェントの環境で HTTPS_PROXY と NODE_USE_ENV_PROXY=1 を設定して (Node 22.21 以降、または 24)、エージェントを再起動してください。NODE_USE_ENV_PROXY がないと、エージェントはプロキシを使用しません。", "approvalModeCeiling": "Muse Code の構成 (既定の権限プロファイル、または管理者が管理するポリシー) では、この権限モードは許可されていません。手動モードなどのより厳しいモードを選択して、もう一度送信してください。" } diff --git a/l10n/ui.ko.json b/l10n/ui.ko.json index 76764297..e680ddd3 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -887,9 +887,36 @@ "editCreatedRemovedPath": "{path}: 휴지통으로 이동했습니다(Muse가 만든 파일).", "modelApiNeedsFolder": "먼저 폴더를 여세요. Model API 백엔드는 작업 영역 안에서 작동합니다.", "modelApiBundleUnavailable": "Model API 백엔드를 불러올 수 없습니다. 확장을 다시 설치하고 창을 다시 로드하세요. 자세한 내용은 로그에 있습니다.", + "acpModelApiBundleUnavailable": "Model API 백엔드를 불러올 수 없습니다. muse-spark-code-acp를 다시 설치하고 에이전트를 다시 시작하세요. 자세한 내용은 에이전트 로그에 있습니다.", "cliNotFound": "Muse Code가 알려진 위치에 설치되어 있지 않습니다.", "cliPathNotAbsolute": "museSpark.museBinaryPath는 절대 경로여야 합니다.", "cliSearched": "검색한 위치: {paths}", + "acpAuthMuseCodeName": "Muse Code에 로그인", + "acpAuthMuseCodeDetail": "터미널에서 Muse Code 자체 로그인을 실행합니다. 대화 요금은 Muse 구독으로 결제됩니다.", + "acpAuthKeyName": "Meta Model API 키 저장", + "acpAuthKeyDetail": "터미널에서 키를 읽어 이 컴퓨터의 자격 증명 저장소에 보관합니다. 대화 요금은 이 키로 청구됩니다.", + "acpSignInByHand": "터미널에서 \"{command}\"을(를) 실행한 다음 다시 시도하세요.", + "acpMuseCodeSignedOut": "Muse Code에 로그인되어 있지 않습니다. 로그인한 다음 다시 시도하세요.", + "acpNoStoredKey": "저장된 Meta Model API 키가 없습니다. 키를 저장한 다음 다시 시도하세요.", + "acpKeyPrompt": "Meta Model API 키(입력하는 동안 표시되지 않음): ", + "acpKeyStored": "키를 저장했습니다: {store}.", + "acpKeyNotStored": "키를 입력하지 않아 아무것도 저장하지 않았습니다.", + "acpKeyPresent": "Meta Model API 키가 저장되어 있습니다: {store}.", + "acpKeyAbsent": "저장된 Meta Model API 키가 없습니다.", + "acpKeyCleared": "이 컴퓨터의 자격 증명 저장소에서 Meta Model API 키를 제거했습니다.", + "acpStoreUnavailable": "이 컴퓨터의 자격 증명 저장소를 사용할 수 없습니다({reason}). Linux에서는 GNOME Keyring이나 KWallet처럼 실행 중이고 잠금 해제된 Secret Service가 에이전트에 필요합니다.", + "acpStoreNames": { + "windows": "Windows 자격 증명 관리자", + "macos": "macOS 키체인", + "linux": "Secret Service 키링" + }, + "acpNoModels": "백엔드에 이 에이전트가 사용할 수 있는 모델이 없습니다.", + "acpPromptBusy": "이 세션에서 이미 프롬프트가 실행 중입니다.", + "acpQuestionFormMessage": "Muse가 질문이 있습니다.", + "acpQuestionAsked": "Muse가 질문이 있습니다. 이 편집기에서는 양식으로 표시할 수 없으니 다음 메시지로 답해 주세요:", + "acpUnknownArgument": "알 수 없는 인수: {argument}", + "acpPaidNeedsModelApi": "{argument}에는 --backend modelApi가 필요합니다. 유료 기능은 Model API 키에 청구됩니다.", + "acpUsage": "사용법:\n {command} [옵션] stdin과 stdout으로 Agent Client Protocol을 제공합니다\n {command} [옵션] login 이 터미널에서 Muse Code에 로그인합니다\n {command} auth set|status|clear Meta Model API 키를 저장, 확인 또는 제거합니다\n옵션:\n --backend museCode|modelApi 결제 주체: Muse Code(기본값) 또는 Model API 키\n --trust-workspace 폴더의 규칙, 스킬, 메모리를 불러옵니다\n --muse-binary <경로> 실행할 Muse Code CLI\n --shell-sandbox auto|muse|off Muse Code의 셸 샌드박스\n --allow-dangerously-skip-permissions \"권한 우회\" 모드를 제공합니다\n --allow-contributor-models 기여자 등급 모델을 표시합니다(Meta가 해당 콘텐츠로 학습할 수 있음)\n --web-search 유료 웹 검색을 제공합니다(Model API 백엔드, 먼저 요금을 묻습니다)\n --image-generation 유료 이미지 생성을 제공합니다(Model API 백엔드, 먼저 요금을 묻습니다)\n --verbose 모든 세부 정보를 stderr에 기록합니다\n --help, --version", "exportSessionLine": "세션: `{id}`", "exportBackendLine": "백엔드: {backend}", "exportModelLine": "모델: {model}", @@ -1100,5 +1127,8 @@ "networkProxyCredentials": "프록시가 자격 증명을 요청했지만 받은 자격 증명을 수락하지 않았습니다. http.proxy와 http.proxyAuthorization 또는 VS Code가 요청한 자격 증명을 확인하세요.", "networkProxyRefused": "프록시가 연결을 거부했습니다(HTTP {status}). api.meta.ai를 허용하는지 확인하세요.", "networkUnreachable": "Meta 서버에 연결할 수 없습니다. 네트워크 연결을 확인하고, 프록시를 사용하는 경우 http.proxy와 http.proxySupport도 확인하세요.", + "acpNetworkUntrustedCertificate": "서버 인증서를 신뢰할 수 없습니다. 네트워크가 HTTPS를 검사하는 경우 에이전트 환경의 NODE_EXTRA_CA_CERTS에 해당 루트 인증서 파일을 지정하거나, 같은 환경의 NODE_OPTIONS에 --use-system-ca를 추가해(Node 22.15 이상) 에이전트가 운영 체제의 인증서 저장소를 신뢰하게 한 다음 에이전트를 다시 시작하세요.", + "acpNetworkProxyCredentials": "프록시가 자격 증명을 요청했지만 받은 자격 증명을 수락하지 않았습니다. 에이전트 환경의 HTTPS_PROXY에 있는 프록시 주소(http://user:password@host:port)의 사용자 이름과 암호를 확인한 다음 에이전트를 다시 시작하세요.", + "acpNetworkUnreachable": "Meta 서버에 연결할 수 없습니다. 네트워크 연결을 확인하세요. 프록시를 사용하는 경우 에이전트 환경에 HTTPS_PROXY와 NODE_USE_ENV_PROXY=1을 설정하고(Node 22.21 이상 또는 24) 에이전트를 다시 시작하세요. NODE_USE_ENV_PROXY가 없으면 에이전트는 프록시를 사용하지 않습니다.", "approvalModeCeiling": "Muse Code 구성(기본 권한 프로필 또는 관리자가 관리하는 정책)에서 이 권한 모드를 허용하지 않습니다. 수동과 같은 더 엄격한 모드를 선택하고 다시 보내세요." } diff --git a/l10n/ui.pl.json b/l10n/ui.pl.json index 7e0ef057..77437d82 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -953,9 +953,36 @@ "editCreatedRemovedPath": "{path}: przeniesiono do kosza (utworzony przez Muse).", "modelApiNeedsFolder": "Najpierw otwórz folder; backend Model API działa wewnątrz obszaru roboczego.", "modelApiBundleUnavailable": "Nie udało się załadować backendu Model API; zainstaluj ponownie rozszerzenie i przeładuj okno. Szczegóły są w dzienniku.", + "acpModelApiBundleUnavailable": "Nie udało się załadować backendu Model API; zainstaluj ponownie muse-spark-code-acp i uruchom agenta ponownie. Szczegóły są w dzienniku agenta.", "cliNotFound": "Muse Code nie jest zainstalowany w żadnej znanej lokalizacji.", "cliPathNotAbsolute": "museSpark.museBinaryPath musi być ścieżką bezwzględną.", "cliSearched": "Przeszukano: {paths}", + "acpAuthMuseCodeName": "Zaloguj się do Muse Code", + "acpAuthMuseCodeDetail": "Uruchamia w terminalu własne logowanie Muse Code. Za rozmowy płaci Twoja subskrypcja Muse.", + "acpAuthKeyName": "Zapisz klucz Meta Model API", + "acpAuthKeyDetail": "Odczytuje Twój klucz w terminalu i przechowuje go w magazynie poświadczeń tego komputera. Rozmowy są rozliczane z klucza.", + "acpSignInByHand": "Uruchom „{command}” w terminalu, a następnie spróbuj ponownie.", + "acpMuseCodeSignedOut": "Muse Code nie jest zalogowany; zaloguj się i spróbuj ponownie.", + "acpNoStoredKey": "Nie zapisano klucza Meta Model API; zapisz klucz i spróbuj ponownie.", + "acpKeyPrompt": "Klucz Meta Model API (niewidoczny podczas wpisywania): ", + "acpKeyStored": "Klucz został zapisany: {store}.", + "acpKeyNotStored": "Nie wpisano klucza, więc nic nie zapisano.", + "acpKeyPresent": "Klucz Meta Model API jest zapisany: {store}.", + "acpKeyAbsent": "Nie zapisano klucza Meta Model API.", + "acpKeyCleared": "Klucz Meta Model API został usunięty z magazynu poświadczeń tego komputera.", + "acpStoreUnavailable": "Nie można użyć magazynu poświadczeń tego komputera ({reason}). W systemie Linux agent potrzebuje uruchomionej i odblokowanej usługi Secret Service, takiej jak GNOME Keyring lub KWallet.", + "acpStoreNames": { + "windows": "Menedżer poświadczeń systemu Windows", + "macos": "Pęk kluczy macOS", + "linux": "pęk kluczy usługi Secret Service" + }, + "acpNoModels": "Backend nie oferuje żadnego modelu, którego ten agent może użyć.", + "acpPromptBusy": "W tej sesji jest już uruchomiony prompt.", + "acpQuestionFormMessage": "Muse ma do Ciebie pytanie.", + "acpQuestionAsked": "Muse ma pytanie; ten edytor nie może go pokazać jako formularza, więc odpowiedz w następnej wiadomości:", + "acpUnknownArgument": "Nieznany argument: {argument}", + "acpPaidNeedsModelApi": "{argument} wymaga --backend modelApi: funkcje płatne są rozliczane z klucza Model API.", + "acpUsage": "Użycie:\n {command} [opcje] Obsługuj Agent Client Protocol przez stdin i stdout\n {command} [opcje] login Zaloguj się do Muse Code w tym terminalu\n {command} auth set|status|clear Zapisz, sprawdź lub usuń klucz Meta Model API\nOpcje:\n --backend museCode|modelApi Kto płaci: Muse Code (domyślnie) lub klucz Model API\n --trust-workspace Wczytaj reguły, umiejętności i pamięć folderu\n --muse-binary <ścieżka> Interfejs CLI Muse Code do uruchomienia\n --shell-sandbox auto|muse|off Piaskownica powłoki Muse Code\n --allow-dangerously-skip-permissions Udostępnij tryb „Pomijanie uprawnień”\n --allow-contributor-models Wyświetl modele poziomu contributor (Meta może trenować na ich treści)\n --web-search Udostępnij płatne wyszukiwanie w sieci (backend Model API; najpierw pyta o cenę)\n --image-generation Udostępnij płatne generowanie obrazów (backend Model API; najpierw pyta o cenę)\n --verbose Zapisuj każdy szczegół w stderr\n --help, --version", "exportSessionLine": "Sesja: `{id}`", "exportBackendLine": "Backend: {backend}", "exportModelLine": "Model: {model}", @@ -1196,5 +1223,8 @@ "networkProxyCredentials": "Serwer proxy zażądał poświadczeń i nie przyjął tych, które otrzymał. Sprawdź http.proxy i http.proxyAuthorization albo poświadczenia, o które poprosił VS Code.", "networkProxyRefused": "Serwer proxy odrzucił połączenie (HTTP {status}). Sprawdź, czy zezwala na api.meta.ai.", "networkUnreachable": "Nie można połączyć się z serwerem Meta. Sprawdź połączenie sieciowe oraz http.proxy i http.proxySupport, jeśli korzystasz z serwera proxy.", + "acpNetworkUntrustedCertificate": "Certyfikat serwera nie jest zaufany. Jeśli Twoja sieć sprawdza ruch HTTPS, wskaż plik jej certyfikatu głównego w NODE_EXTRA_CA_CERTS w środowisku agenta albo dodaj tam --use-system-ca do NODE_OPTIONS (Node 22.15 lub nowszy), aby agent ufał magazynowi certyfikatów systemu operacyjnego, a następnie uruchom agenta ponownie.", + "acpNetworkProxyCredentials": "Serwer proxy zażądał poświadczeń i nie przyjął tych, które otrzymał. Sprawdź nazwę użytkownika i hasło w adresie serwera proxy w HTTPS_PROXY (http://user:password@host:port) w środowisku agenta, a następnie uruchom agenta ponownie.", + "acpNetworkUnreachable": "Nie można połączyć się z serwerem Meta. Sprawdź połączenie sieciowe. Za serwerem proxy ustaw HTTPS_PROXY i NODE_USE_ENV_PROXY=1 w środowisku agenta (Node 22.21 lub nowszy albo 24) i uruchom agenta ponownie: bez NODE_USE_ENV_PROXY agent nie korzysta z serwera proxy.", "approvalModeCeiling": "Konfiguracja Muse Code (jej domyślny profil uprawnień lub zasada zarządzana przez Twojego administratora) nie zezwala na ten tryb uprawnień. Wybierz bardziej restrykcyjny, na przykład „Ręczny”, i wyślij ponownie." } diff --git a/l10n/ui.pt-br.json b/l10n/ui.pt-br.json index e1583053..46a567d5 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -931,9 +931,36 @@ "editCreatedRemovedPath": "{path}: movido para a lixeira (o Muse o criou).", "modelApiNeedsFolder": "Abra uma pasta primeiro; o back-end da Model API trabalha dentro de um espaço de trabalho.", "modelApiBundleUnavailable": "Não foi possível carregar o back-end da Model API; reinstale a extensão e recarregue a janela. O log tem os detalhes.", + "acpModelApiBundleUnavailable": "Não foi possível carregar o back-end da Model API; reinstale o muse-spark-code-acp e reinicie o agente. O log do agente tem os detalhes.", "cliNotFound": "O Muse Code não está instalado em nenhum local conhecido.", "cliPathNotAbsolute": "museSpark.museBinaryPath deve ser um caminho absoluto.", "cliSearched": "Locais pesquisados: {paths}", + "acpAuthMuseCodeName": "Entrar no Muse Code", + "acpAuthMuseCodeDetail": "Executa o login do próprio Muse Code em um terminal. Sua assinatura do Muse paga as conversas.", + "acpAuthKeyName": "Guardar uma chave da Meta Model API", + "acpAuthKeyDetail": "Lê sua chave em um terminal e a guarda no armazenamento de credenciais deste computador. As conversas são cobradas da chave.", + "acpSignInByHand": "Execute “{command}” em um terminal e tente novamente.", + "acpMuseCodeSignedOut": "O Muse Code não está conectado; entre e tente novamente.", + "acpNoStoredKey": "Nenhuma chave da Meta Model API está guardada; guarde uma e tente novamente.", + "acpKeyPrompt": "Chave da Meta Model API (não aparece enquanto você digita): ", + "acpKeyStored": "A chave está guardada: {store}.", + "acpKeyNotStored": "Nenhuma chave foi digitada, então nada foi guardado.", + "acpKeyPresent": "Há uma chave da Meta Model API guardada: {store}.", + "acpKeyAbsent": "Nenhuma chave da Meta Model API está guardada.", + "acpKeyCleared": "A chave da Meta Model API foi removida do armazenamento de credenciais deste computador.", + "acpStoreUnavailable": "Não é possível usar o armazenamento de credenciais deste computador ({reason}). No Linux, o agente precisa de um Secret Service em execução e desbloqueado, como o GNOME Keyring ou o KWallet.", + "acpStoreNames": { + "windows": "Gerenciador de Credenciais do Windows", + "macos": "Chaves do macOS", + "linux": "chaveiro do Secret Service" + }, + "acpNoModels": "O backend não oferece nenhum modelo que este agente possa usar.", + "acpPromptBusy": "Já há um prompt em andamento nesta sessão.", + "acpQuestionFormMessage": "O Muse tem uma pergunta para você.", + "acpQuestionAsked": "O Muse tem uma pergunta; este editor não consegue mostrá-la como formulário, então responda na sua próxima mensagem:", + "acpUnknownArgument": "Argumento desconhecido: {argument}", + "acpPaidNeedsModelApi": "{argument} requer --backend modelApi: os recursos pagos são cobrados de uma chave da Model API.", + "acpUsage": "Uso:\n {command} [opções] Servir o Agent Client Protocol em stdin e stdout\n {command} [opções] login Entrar no Muse Code neste terminal\n {command} auth set|status|clear Guardar, verificar ou remover a chave da Meta Model API\nOpções:\n --backend museCode|modelApi Quem paga: o Muse Code (padrão) ou a chave da Model API\n --trust-workspace Carregar as regras, as skills e a memória da pasta\n --muse-binary A CLI do Muse Code a executar\n --shell-sandbox auto|muse|off A sandbox do shell do Muse Code\n --allow-dangerously-skip-permissions Oferecer o modo “Ignorar permissões”\n --allow-contributor-models Listar os modelos de nível colaborador (a Meta pode treinar com o conteúdo deles)\n --web-search Oferecer a pesquisa na web paga (back-end da Model API; o preço é perguntado antes)\n --image-generation Oferecer a geração de imagens paga (back-end da Model API; o preço é perguntado antes)\n --verbose Registrar cada detalhe em stderr\n --help, --version", "exportSessionLine": "Sessão: `{id}`", "exportBackendLine": "Back-end: {backend}", "exportModelLine": "Modelo: {model}", @@ -1164,5 +1191,8 @@ "networkProxyCredentials": "O proxy pediu credenciais e não aceitou as que recebeu. Verifique http.proxy e http.proxyAuthorization, ou as credenciais que o VS Code pediu a você.", "networkProxyRefused": "O proxy recusou a conexão (HTTP {status}). Verifique se ele permite api.meta.ai.", "networkUnreachable": "Não foi possível acessar o servidor da Meta. Verifique a conexão de rede e, se você usa um proxy, http.proxy e http.proxySupport.", + "acpNetworkUntrustedCertificate": "O certificado do servidor não é confiável. Se a sua rede inspeciona HTTPS, indique o arquivo do certificado raiz dela em NODE_EXTRA_CA_CERTS no ambiente do agente ou adicione ali --use-system-ca a NODE_OPTIONS (Node 22.15 ou posterior) para que o agente confie no repositório de certificados do sistema operacional e, depois, reinicie o agente.", + "acpNetworkProxyCredentials": "O proxy pediu credenciais e não aceitou as que recebeu. Verifique o nome de usuário e a senha no endereço do proxy em HTTPS_PROXY (http://user:password@host:port) no ambiente do agente e, depois, reinicie o agente.", + "acpNetworkUnreachable": "Não foi possível acessar o servidor da Meta. Verifique a conexão de rede. Atrás de um proxy, defina HTTPS_PROXY e NODE_USE_ENV_PROXY=1 no ambiente do agente (Node 22.21 ou posterior, ou 24) e reinicie o agente: sem NODE_USE_ENV_PROXY, o agente não usa o proxy.", "approvalModeCeiling": "A configuração do Muse Code (o perfil de permissões padrão dele ou uma política gerenciada pelo seu administrador) não permite este modo de permissão. Escolha um mais restrito, como Manual, e envie novamente." } diff --git a/l10n/ui.ru.json b/l10n/ui.ru.json index ba78dee0..2bd553b9 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -953,9 +953,36 @@ "editCreatedRemovedPath": "{path}: перемещен в корзину (его создал Muse).", "modelApiNeedsFolder": "Сначала откройте папку; бэкенд Model API работает внутри рабочей области.", "modelApiBundleUnavailable": "Не удалось загрузить бэкенд Model API; переустановите расширение и перезагрузите окно. Подробности — в журнале.", + "acpModelApiBundleUnavailable": "Не удалось загрузить бэкенд Model API; переустановите muse-spark-code-acp и перезапустите агент. Подробности — в журнале агента.", "cliNotFound": "Muse Code не установлен ни в одном из известных расположений.", "cliPathNotAbsolute": "museSpark.museBinaryPath должен быть абсолютным путем.", "cliSearched": "Проверено: {paths}", + "acpAuthMuseCodeName": "Войти в Muse Code", + "acpAuthMuseCodeDetail": "Запускает собственный вход Muse Code в терминале. Разговоры оплачивает ваша подписка Muse.", + "acpAuthKeyName": "Сохранить ключ Meta Model API", + "acpAuthKeyDetail": "Считывает ваш ключ в терминале и хранит его в хранилище учетных данных этого компьютера. Разговоры оплачиваются по ключу.", + "acpSignInByHand": "Выполните «{command}» в терминале и повторите попытку.", + "acpMuseCodeSignedOut": "Вход в Muse Code не выполнен; войдите и повторите попытку.", + "acpNoStoredKey": "Ключ Meta Model API не сохранен; сохраните ключ и повторите попытку.", + "acpKeyPrompt": "Ключ Meta Model API (не отображается при вводе): ", + "acpKeyStored": "Ключ сохранен: {store}.", + "acpKeyNotStored": "Ключ не введен, поэтому ничего не сохранено.", + "acpKeyPresent": "Ключ Meta Model API сохранен: {store}.", + "acpKeyAbsent": "Ключ Meta Model API не сохранен.", + "acpKeyCleared": "Ключ Meta Model API удален из хранилища учетных данных этого компьютера.", + "acpStoreUnavailable": "Хранилище учетных данных этого компьютера недоступно ({reason}). В Linux агенту нужна запущенная и разблокированная служба Secret Service, например GNOME Keyring или KWallet.", + "acpStoreNames": { + "windows": "Диспетчер учетных данных Windows", + "macos": "Связка ключей macOS", + "linux": "связка ключей Secret Service" + }, + "acpNoModels": "Серверная часть не предлагает ни одной модели, которую может использовать этот агент.", + "acpPromptBusy": "В этом сеансе уже выполняется запрос.", + "acpQuestionFormMessage": "У Muse есть к вам вопрос.", + "acpQuestionAsked": "У Muse есть вопрос; этот редактор не может показать его в виде формы, поэтому ответьте в следующем сообщении:", + "acpUnknownArgument": "Неизвестный аргумент: {argument}", + "acpPaidNeedsModelApi": "{argument} требует --backend modelApi: платные функции оплачиваются с ключа Model API.", + "acpUsage": "Использование:\n {command} [параметры] Обслуживать Agent Client Protocol через stdin и stdout\n {command} [параметры] login Войти в Muse Code в этом терминале\n {command} auth set|status|clear Сохранить, проверить или удалить ключ Meta Model API\nПараметры:\n --backend museCode|modelApi Кто платит: Muse Code (по умолчанию) или ключ Model API\n --trust-workspace Загрузить правила, навыки и память папки\n --muse-binary <путь> Запускаемый CLI Muse Code\n --shell-sandbox auto|muse|off Песочница оболочки Muse Code\n --allow-dangerously-skip-permissions Предлагать режим «Обход разрешений»\n --allow-contributor-models Показывать модели уровня contributor (Meta может обучаться на их содержимом)\n --web-search Предлагать платный веб-поиск (бэкенд Model API; сначала запрашивается согласие с ценой)\n --image-generation Предлагать платное создание изображений (бэкенд Model API; сначала запрашивается согласие с ценой)\n --verbose Записывать все подробности в stderr\n --help, --version", "exportSessionLine": "Сеанс: `{id}`", "exportBackendLine": "Бэкенд: {backend}", "exportModelLine": "Модель: {model}", @@ -1196,5 +1223,8 @@ "networkProxyCredentials": "Прокси-сервер запросил учетные данные и не принял полученные. Проверьте http.proxy и http.proxyAuthorization или учетные данные, которые запрашивал VS Code.", "networkProxyRefused": "Прокси-сервер отклонил подключение (HTTP {status}). Убедитесь, что он разрешает api.meta.ai.", "networkUnreachable": "Не удалось связаться с сервером Meta. Проверьте сетевое подключение, а также http.proxy и http.proxySupport, если вы используете прокси-сервер.", + "acpNetworkUntrustedCertificate": "Сертификат сервера не является доверенным. Если ваша сеть проверяет HTTPS, укажите файл ее корневого сертификата в NODE_EXTRA_CA_CERTS в окружении агента или добавьте там --use-system-ca в NODE_OPTIONS (Node 22.15 или новее), чтобы агент доверял хранилищу сертификатов операционной системы, а затем перезапустите агент.", + "acpNetworkProxyCredentials": "Прокси-сервер запросил учетные данные и не принял полученные. Проверьте имя пользователя и пароль в адресе прокси-сервера в HTTPS_PROXY (http://user:password@host:port) в окружении агента, а затем перезапустите агент.", + "acpNetworkUnreachable": "Не удалось связаться с сервером Meta. Проверьте сетевое подключение. Если вы работаете через прокси-сервер, задайте HTTPS_PROXY и NODE_USE_ENV_PROXY=1 в окружении агента (Node 22.21 или новее либо 24) и перезапустите агент: без NODE_USE_ENV_PROXY агент не использует прокси-сервер.", "approvalModeCeiling": "Конфигурация Muse Code (ее профиль разрешений по умолчанию или политика, которой управляет ваш администратор) не разрешает этот режим разрешений. Выберите более строгий режим, например «Ручной», и отправьте сообщение еще раз." } diff --git a/l10n/ui.tr.json b/l10n/ui.tr.json index 6d216167..5370ce9a 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -909,9 +909,36 @@ "editCreatedRemovedPath": "{path}: Çöp kutusuna taşındı (Muse oluşturmuştu).", "modelApiNeedsFolder": "Önce bir klasör açın; Model API arka ucu bir çalışma alanı içinde çalışır.", "modelApiBundleUnavailable": "Model API arka ucu yüklenemedi; uzantıyı yeniden kurun ve pencereyi yeniden yükleyin. Ayrıntılar günlüktedir.", + "acpModelApiBundleUnavailable": "Model API arka ucu yüklenemedi; muse-spark-code-acp paketini yeniden kurun ve aracıyı yeniden başlatın. Ayrıntılar aracının günlüğündedir.", "cliNotFound": "Muse Code bilinen hiçbir konumda yüklü değil.", "cliPathNotAbsolute": "museSpark.museBinaryPath mutlak bir yol olmalıdır.", "cliSearched": "Aranan yerler: {paths}", + "acpAuthMuseCodeName": "Muse Code'da oturum açın", + "acpAuthMuseCodeDetail": "Muse Code'un kendi oturum açma işlemini bir terminalde çalıştırır. Konuşmaların ücretini Muse aboneliğiniz öder.", + "acpAuthKeyName": "Bir Meta Model API anahtarı saklayın", + "acpAuthKeyDetail": "Anahtarınızı bir terminalde okur ve bu bilgisayarın kimlik bilgisi deposunda saklar. Konuşmalar anahtara faturalandırılır.", + "acpSignInByHand": "Bir terminalde \"{command}\" komutunu çalıştırın, ardından yeniden deneyin.", + "acpMuseCodeSignedOut": "Muse Code'da oturum açılmamış; oturum açıp yeniden deneyin.", + "acpNoStoredKey": "Saklanan Meta Model API anahtarı yok; bir anahtar saklayıp yeniden deneyin.", + "acpKeyPrompt": "Meta Model API anahtarı (yazarken gösterilmez): ", + "acpKeyStored": "Anahtar saklandı: {store}.", + "acpKeyNotStored": "Anahtar girilmedi, bu yüzden hiçbir şey saklanmadı.", + "acpKeyPresent": "Bir Meta Model API anahtarı saklanıyor: {store}.", + "acpKeyAbsent": "Saklanan Meta Model API anahtarı yok.", + "acpKeyCleared": "Meta Model API anahtarı bu bilgisayarın kimlik bilgisi deposundan kaldırıldı.", + "acpStoreUnavailable": "Bu bilgisayarın kimlik bilgisi deposu kullanılamıyor ({reason}). Linux'ta aracının GNOME Keyring veya KWallet gibi çalışan ve kilidi açık bir Secret Service hizmetine ihtiyacı vardır.", + "acpStoreNames": { + "windows": "Windows Kimlik Bilgisi Yöneticisi", + "macos": "macOS Anahtar Zinciri", + "linux": "Secret Service anahtarlığı" + }, + "acpNoModels": "Arka uç, bu aracının kullanabileceği bir model sunmuyor.", + "acpPromptBusy": "Bu oturumda zaten bir istem çalışıyor.", + "acpQuestionFormMessage": "Muse'un size bir sorusu var.", + "acpQuestionAsked": "Muse'un bir sorusu var; bu düzenleyici soruyu form olarak gösteremiyor, bu yüzden bir sonraki iletinizde yanıtlayın:", + "acpUnknownArgument": "Bilinmeyen bağımsız değişken: {argument}", + "acpPaidNeedsModelApi": "{argument} için --backend modelApi gerekir: ücretli özellikler bir Model API anahtarına faturalandırılır.", + "acpUsage": "Kullanım:\n {command} [seçenekler] Agent Client Protocol'ü stdin ve stdout üzerinden sunar\n {command} [seçenekler] login Bu terminalde Muse Code'da oturum açar\n {command} auth set|status|clear Meta Model API anahtarını saklar, denetler veya kaldırır\nSeçenekler:\n --backend museCode|modelApi Kim öder: Muse Code (varsayılan) veya Model API anahtarı\n --trust-workspace Klasörün kurallarını, becerilerini ve belleğini yükler\n --muse-binary Çalıştırılacak Muse Code CLI\n --shell-sandbox auto|muse|off Muse Code'un kabuk korumalı alanı\n --allow-dangerously-skip-permissions \"İzinleri atla\" modunu sunar\n --allow-contributor-models Katkıda bulunan düzeyindeki modelleri listeler (Meta bunların içeriğiyle eğitim yapabilir)\n --web-search Ücretli web aramasını sunar (Model API arka ucu; önce fiyatı sorulur)\n --image-generation Ücretli görüntü oluşturmayı sunar (Model API arka ucu; önce fiyatı sorulur)\n --verbose Her ayrıntıyı stderr'e kaydeder\n --help, --version", "exportSessionLine": "Oturum: `{id}`", "exportBackendLine": "Arka uç: {backend}", "exportModelLine": "Model: {model}", @@ -1132,5 +1159,8 @@ "networkProxyCredentials": "Ara sunucu kimlik bilgileri istedi ve aldıklarını kabul etmedi. http.proxy ve http.proxyAuthorization ayarlarını veya VS Code'un sizden istediği kimlik bilgilerini denetleyin.", "networkProxyRefused": "Ara sunucu bağlantıyı reddetti (HTTP {status}). api.meta.ai adresine izin verdiğini denetleyin.", "networkUnreachable": "Meta'nın sunucusuna ulaşılamadı. Ağ bağlantısını ve ara sunucu kullanıyorsanız http.proxy ile http.proxySupport ayarlarını denetleyin.", + "acpNetworkUntrustedCertificate": "Sunucunun sertifikasına güvenilmiyor. Ağınız HTTPS trafiğini denetliyorsa, kök sertifika dosyasını aracının ortamındaki NODE_EXTRA_CA_CERTS ile belirtin ya da aracının işletim sisteminin sertifika deposuna güvenmesi için aynı ortamda NODE_OPTIONS'a --use-system-ca ekleyin (Node 22.15 veya üstü), ardından aracıyı yeniden başlatın.", + "acpNetworkProxyCredentials": "Ara sunucu kimlik bilgileri istedi ve aldıklarını kabul etmedi. Aracının ortamındaki HTTPS_PROXY içinde ara sunucu adresindeki (http://user:password@host:port) kullanıcı adını ve parolayı denetleyin, ardından aracıyı yeniden başlatın.", + "acpNetworkUnreachable": "Meta'nın sunucusuna ulaşılamadı. Ağ bağlantısını denetleyin. Bir ara sunucunun arkasındaysanız aracının ortamında HTTPS_PROXY ve NODE_USE_ENV_PROXY=1 değerlerini ayarlayın (Node 22.21 veya üstü ya da 24) ve aracıyı yeniden başlatın: NODE_USE_ENV_PROXY olmadan aracı ara sunucuyu kullanmaz.", "approvalModeCeiling": "Muse Code'un yapılandırması (varsayılan izin profili veya yöneticinizin yönettiği bir ilke) bu izin moduna izin vermiyor. El ile gibi daha katı bir mod seçin ve yeniden gönderin." } diff --git a/l10n/ui.zh-cn.json b/l10n/ui.zh-cn.json index e1eb49cd..fcc7b250 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -887,9 +887,36 @@ "editCreatedRemovedPath": "{path}:已移至回收站(该文件由 Muse 创建)。", "modelApiNeedsFolder": "请先打开文件夹;Model API 后端在工作区中工作。", "modelApiBundleUnavailable": "无法加载 Model API 后端;请重新安装扩展并重新加载窗口。详细信息见日志。", + "acpModelApiBundleUnavailable": "无法加载 Model API 后端;请重新安装 muse-spark-code-acp 并重新启动代理。详细信息见代理的日志。", "cliNotFound": "任何已知位置均未安装 Muse Code。", "cliPathNotAbsolute": "museSpark.museBinaryPath 必须是绝对路径。", "cliSearched": "已搜索:{paths}", + "acpAuthMuseCodeName": "登录 Muse Code", + "acpAuthMuseCodeDetail": "在终端中运行 Muse Code 自身的登录。对话费用由你的 Muse 订阅支付。", + "acpAuthKeyName": "存储 Meta Model API 密钥", + "acpAuthKeyDetail": "在终端中读取你的密钥,并将其保存在这台计算机的凭据存储中。对话费用计入该密钥。", + "acpSignInByHand": "请在终端中运行“{command}”,然后重试。", + "acpMuseCodeSignedOut": "Muse Code 尚未登录;请登录后重试。", + "acpNoStoredKey": "未存储 Meta Model API 密钥;请存储一个密钥后重试。", + "acpKeyPrompt": "Meta Model API 密钥(输入时不显示):", + "acpKeyStored": "密钥已存储:{store}。", + "acpKeyNotStored": "未输入密钥,因此未存储任何内容。", + "acpKeyPresent": "已存储 Meta Model API 密钥:{store}。", + "acpKeyAbsent": "未存储 Meta Model API 密钥。", + "acpKeyCleared": "已从这台计算机的凭据存储中移除 Meta Model API 密钥。", + "acpStoreUnavailable": "无法使用这台计算机的凭据存储({reason})。在 Linux 上,代理需要一个正在运行且已解锁的 Secret Service,例如 GNOME Keyring 或 KWallet。", + "acpStoreNames": { + "windows": "Windows 凭据管理器", + "macos": "macOS 钥匙串", + "linux": "Secret Service 密钥环" + }, + "acpNoModels": "后端没有提供此代理可用的模型。", + "acpPromptBusy": "此会话中已有一个提示正在运行。", + "acpQuestionFormMessage": "Muse 有一个问题要问你。", + "acpQuestionAsked": "Muse 有一个问题;此编辑器无法以表单形式显示,请在下一条消息中回答:", + "acpUnknownArgument": "未知参数:{argument}", + "acpPaidNeedsModelApi": "{argument} 需要 --backend modelApi:付费功能会计费到 Model API 密钥。", + "acpUsage": "用法:\n {command} [选项] 通过 stdin 和 stdout 提供 Agent Client Protocol\n {command} [选项] login 在此终端中登录 Muse Code\n {command} auth set|status|clear 存储、检查或移除 Meta Model API 密钥\n选项:\n --backend museCode|modelApi 由谁付费:Muse Code(默认)或 Model API 密钥\n --trust-workspace 加载文件夹的规则、技能和记忆\n --muse-binary <路径> 要运行的 Muse Code CLI\n --shell-sandbox auto|muse|off Muse Code 的 shell 沙盒\n --allow-dangerously-skip-permissions 提供“绕过权限”模式\n --allow-contributor-models 列出贡献者级模型(Meta 可能使用其内容进行训练)\n --web-search 提供付费网页搜索(Model API 后端;会先询问价格)\n --image-generation 提供付费图片生成(Model API 后端;会先询问价格)\n --verbose 在 stderr 上记录所有细节\n --help, --version", "exportSessionLine": "会话:`{id}`", "exportBackendLine": "后端:{backend}", "exportModelLine": "模型:{model}", @@ -1100,5 +1127,8 @@ "networkProxyCredentials": "代理要求提供凭据,但未接受收到的凭据。请检查 http.proxy 和 http.proxyAuthorization,或 VS Code 向你询问的凭据。", "networkProxyRefused": "代理拒绝了连接(HTTP {status})。请检查它是否允许 api.meta.ai。", "networkUnreachable": "无法连接到 Meta 的服务器。请检查网络连接;如果使用代理,还请检查 http.proxy 和 http.proxySupport。", + "acpNetworkUntrustedCertificate": "服务器的证书不受信任。如果你的网络会检查 HTTPS 流量,请在代理的环境中用 NODE_EXTRA_CA_CERTS 指定其根证书文件,或在该环境的 NODE_OPTIONS 中添加 --use-system-ca(Node 22.15 或更高版本),让代理信任操作系统的证书存储,然后重新启动代理。", + "acpNetworkProxyCredentials": "代理服务器要求提供凭据,但未接受收到的凭据。请检查代理环境中 HTTPS_PROXY 里代理服务器地址(http://user:password@host:port)的用户名和密码,然后重新启动代理。", + "acpNetworkUnreachable": "无法连接到 Meta 的服务器。请检查网络连接。如果使用代理服务器,请在代理的环境中设置 HTTPS_PROXY 和 NODE_USE_ENV_PROXY=1(Node 22.21 或更高版本,或 24),然后重新启动代理:没有 NODE_USE_ENV_PROXY,代理不会使用代理服务器。", "approvalModeCeiling": "Muse Code 的配置(其默认权限配置文件,或由管理员管理的策略)不允许此权限模式。请选择更严格的模式(例如“手动”),然后重新发送。" } diff --git a/l10n/ui.zh-tw.json b/l10n/ui.zh-tw.json index 9e04c9a5..2f2c21d6 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -887,9 +887,36 @@ "editCreatedRemovedPath": "{path}:已移至資源回收筒(該檔案由 Muse 建立)。", "modelApiNeedsFolder": "請先開啟資料夾;Model API 後端需在工作區內運作。", "modelApiBundleUnavailable": "無法載入 Model API 後端;請重新安裝擴充功能並重新載入視窗。詳細資訊請見記錄。", + "acpModelApiBundleUnavailable": "無法載入 Model API 後端;請重新安裝 muse-spark-code-acp 並重新啟動代理程式。詳細資訊請見代理程式的記錄。", "cliNotFound": "任何已知位置都沒有安裝 Muse Code。", "cliPathNotAbsolute": "museSpark.museBinaryPath 必須是絕對路徑。", "cliSearched": "已搜尋:{paths}", + "acpAuthMuseCodeName": "登入 Muse Code", + "acpAuthMuseCodeDetail": "在終端機中執行 Muse Code 本身的登入。對話費用由你的 Muse 訂閱支付。", + "acpAuthKeyName": "儲存 Meta Model API 金鑰", + "acpAuthKeyDetail": "在終端機中讀取你的金鑰,並保存在這台電腦的認證儲存區中。對話費用會計入該金鑰。", + "acpSignInByHand": "請在終端機中執行「{command}」,然後再試一次。", + "acpMuseCodeSignedOut": "Muse Code 尚未登入;請登入後再試一次。", + "acpNoStoredKey": "尚未儲存 Meta Model API 金鑰;請儲存金鑰後再試一次。", + "acpKeyPrompt": "Meta Model API 金鑰(輸入時不會顯示):", + "acpKeyStored": "金鑰已儲存:{store}。", + "acpKeyNotStored": "未輸入金鑰,因此未儲存任何內容。", + "acpKeyPresent": "已儲存 Meta Model API 金鑰:{store}。", + "acpKeyAbsent": "尚未儲存 Meta Model API 金鑰。", + "acpKeyCleared": "已從這台電腦的認證儲存區移除 Meta Model API 金鑰。", + "acpStoreUnavailable": "無法使用這台電腦的認證儲存區({reason})。在 Linux 上,代理程式需要一個執行中且已解鎖的 Secret Service,例如 GNOME Keyring 或 KWallet。", + "acpStoreNames": { + "windows": "Windows 認證管理員", + "macos": "macOS 鑰匙圈", + "linux": "Secret Service 金鑰圈" + }, + "acpNoModels": "後端未提供此代理程式可使用的模型。", + "acpPromptBusy": "此工作階段已有提示正在執行。", + "acpQuestionFormMessage": "Muse 有個問題要問你。", + "acpQuestionAsked": "Muse 有個問題;此編輯器無法以表單顯示,請在下一則訊息中回答:", + "acpUnknownArgument": "未知的引數:{argument}", + "acpPaidNeedsModelApi": "{argument} 需要 --backend modelApi:付費功能會向 Model API 金鑰計費。", + "acpUsage": "用法:\n {command} [選項] 透過 stdin 與 stdout 提供 Agent Client Protocol\n {command} [選項] login 在此終端機中登入 Muse Code\n {command} auth set|status|clear 儲存、檢查或移除 Meta Model API 金鑰\n選項:\n --backend museCode|modelApi 由誰付費:Muse Code(預設)或 Model API 金鑰\n --trust-workspace 載入資料夾的規則、技能與記憶\n --muse-binary <路徑> 要執行的 Muse Code CLI\n --shell-sandbox auto|muse|off Muse Code 的 shell 沙箱\n --allow-dangerously-skip-permissions 提供「略過權限」模式\n --allow-contributor-models 列出貢獻者層級模型(Meta 可能會以其內容進行訓練)\n --web-search 提供付費網頁搜尋(Model API 後端;會先詢問價格)\n --image-generation 提供付費圖片產生(Model API 後端;會先詢問價格)\n --verbose 在 stderr 記錄所有細節\n --help, --version", "exportSessionLine": "工作階段:`{id}`", "exportBackendLine": "後端:{backend}", "exportModelLine": "模型:{model}", @@ -1100,5 +1127,8 @@ "networkProxyCredentials": "Proxy 要求提供認證,但不接受收到的認證。請檢查 http.proxy 和 http.proxyAuthorization,或 VS Code 向您詢問的認證。", "networkProxyRefused": "Proxy 拒絕了連線(HTTP {status})。請檢查它是否允許 api.meta.ai。", "networkUnreachable": "無法連線到 Meta 的伺服器。請檢查網路連線;如果使用 Proxy,也請檢查 http.proxy 和 http.proxySupport。", + "acpNetworkUntrustedCertificate": "伺服器的憑證不受信任。如果您的網路會檢查 HTTPS 流量,請在代理程式的環境中以 NODE_EXTRA_CA_CERTS 指定其根憑證檔案,或在該環境的 NODE_OPTIONS 中加入 --use-system-ca(Node 22.15 或更新版本),讓代理程式信任作業系統的憑證存放區,然後重新啟動代理程式。", + "acpNetworkProxyCredentials": "Proxy 要求提供認證,但不接受收到的認證。請檢查代理程式環境中 HTTPS_PROXY 裡 Proxy 位址(http://user:password@host:port)的使用者名稱和密碼,然後重新啟動代理程式。", + "acpNetworkUnreachable": "無法連線到 Meta 的伺服器。請檢查網路連線。如果使用 Proxy,請在代理程式的環境中設定 HTTPS_PROXY 和 NODE_USE_ENV_PROXY=1(Node 22.21 或更新版本,或 24),然後重新啟動代理程式:沒有 NODE_USE_ENV_PROXY,代理程式不會使用 Proxy。", "approvalModeCeiling": "Muse Code 的設定(其預設權限設定檔,或由系統管理員管理的原則)不允許此權限模式。請選擇更嚴格的模式(例如「手動」),然後重新傳送。" } diff --git a/package-lock.json b/package-lock.json index e31b5bf6..926fe195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ "remark-gfm": "4.0.1" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@eslint/js": "10.0.1", "@muse-code/sdk": "1.3.0", + "@napi-rs/keyring": "2.1.0", "@testing-library/dom": "10.4.2", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", @@ -23,7 +25,7 @@ "@types/node": "22.20.4", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@types/vscode": "1.125.0", + "@types/vscode": "1.99.0", "@types/vscode-webview": "1.57.5", "@vitest/coverage-v8": "5.0.1", "@vscode/test-cli": "0.0.15", @@ -41,6 +43,8 @@ "knip": "6.37.0", "lint-staged": "17.5.1", "npm-run-all2": "9.0.3", + "ovsx": "1.2.0", + "playwright-core": "1.63.0", "prettier": "3.9.8", "react": "19.3.0", "react-dom": "19.3.0", @@ -54,7 +58,7 @@ }, "engines": { "node": ">=22", - "vscode": "^1.125.0" + "vscode": "^1.99.0" } }, "node_modules/@adobe/css-tools": { @@ -64,6 +68,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz", + "integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", @@ -117,6 +131,23 @@ "node": "20 || >=22" } }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, "node_modules/@azure/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", @@ -1525,175 +1556,206 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "node_modules/@inquirer/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=6.0.0" + "node": ">=8" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">= 18" + "node": ">=18" }, "peerDependencies": { - "keyv": "^5.6.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@muse-code/sdk": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@muse-code/sdk/-/sdk-1.3.0.tgz", - "integrity": "sha512-hl1gws0KhYa8FX7dQRsam3HwzcB/wD8NNrCzoQo9Yr2ilGBB8Mlr0DBVXFZJHFKFe7HkgeppKyYetJoCpuMdag==", + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, "engines": { - "node": ">=20" + "node": ">=18" }, "peerDependencies": { - "@types/node": ">=20" + "@types/node": ">=18" }, "peerDependenciesMeta": { "@types/node": { @@ -1701,72 +1763,635 @@ } } }, - "node_modules/@napi-rs/keyring": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", - "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "dev": true, "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, "engines": { - "node": ">= 10" + "node": ">=18" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@types/node": ">=18" }, - "optionalDependencies": { - "@napi-rs/keyring-darwin-arm64": "1.3.0", - "@napi-rs/keyring-darwin-x64": "1.3.0", - "@napi-rs/keyring-freebsd-x64": "1.3.0", - "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", - "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", - "@napi-rs/keyring-linux-arm64-musl": "1.3.0", - "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", - "@napi-rs/keyring-linux-x64-gnu": "1.3.0", - "@napi-rs/keyring-linux-x64-musl": "1.3.0", - "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", - "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", - "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@napi-rs/keyring-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 10" + "node": ">=18" } }, - "node_modules/@napi-rs/keyring-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", - "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, "engines": { - "node": ">= 10" - } + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@muse-code/sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@muse-code/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-hl1gws0KhYa8FX7dQRsam3HwzcB/wD8NNrCzoQo9Yr2ilGBB8Mlr0DBVXFZJHFKFe7HkgeppKyYetJoCpuMdag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/node": ">=20" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/keyring": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.0.tgz", + "integrity": "sha512-km9J3fomkGSLpImpelq0cMvLBrJ5eVlTzuWdG3Iy0laP6gU90MmHYjqhUhG32aYq0/0++r2TLuAA9xlnh4XU1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "2.1.0", + "@napi-rs/keyring-darwin-x64": "2.1.0", + "@napi-rs/keyring-freebsd-x64": "2.1.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "2.1.0", + "@napi-rs/keyring-linux-arm64-gnu": "2.1.0", + "@napi-rs/keyring-linux-arm64-musl": "2.1.0", + "@napi-rs/keyring-linux-riscv64-gnu": "2.1.0", + "@napi-rs/keyring-linux-x64-gnu": "2.1.0", + "@napi-rs/keyring-linux-x64-musl": "2.1.0", + "@napi-rs/keyring-win32-arm64-msvc": "2.1.0", + "@napi-rs/keyring-win32-ia32-msvc": "2.1.0", + "@napi-rs/keyring-win32-x64-msvc": "2.1.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.1.0.tgz", + "integrity": "sha512-j6ATTOhmPW+N5EKwi8Yzn6P4Bz/uqnxzyYvqOL73/dgzDSYG5pS5eprqpVmOLse68WUTf79ZMqz97HnfvirJ9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.1.0.tgz", + "integrity": "sha512-54Q803nguiOaQzBG2h4uIkacI0eLGAlzzdpxPNi6IZtXbjqbPI4AQ86GrQy25tw3czBqRPBoGu0lHP2U+/f5iA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.1.0.tgz", + "integrity": "sha512-BL7ngJAYQBrUp1PTF8enOXOIVfxN51RbwVOEQ+8eAjWypF/5Ipqs8oQoMqkuc0wAbk+vatnNVjdKCIFa/HPglg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.1.0.tgz", + "integrity": "sha512-JYgbCDk+giss7lKla8NTXhM/fLj+Hrfgr3xwKQ8jKn8qMD6vXLwYcfK8HJ9VoAcjIeYEUpCL6dRszJC5M4zFNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.1.0.tgz", + "integrity": "sha512-+PMONFQ+2GkBXG7vRG2spgR73bNCMeVpNopHoSmhj2E+5Gu6zJooRBjoaIZKj6hCyrOU8pXAhoZ22EEFQ/4YeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.1.0.tgz", + "integrity": "sha512-dKjiXKTHQjAS57hL0HVzLpW9XYNGFpfYkr/51FzIIvbZR08PAnzIDYKwShoNYGvjQP6+rDFf1a5Utl0aWwcFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.1.0.tgz", + "integrity": "sha512-tqhb0ofhtEJ2+ZfmlKTDHbmIoNSz+SSTPWRgMfSVCgI4JVkqDdfcmIOcThZfRffJ3QI9C3Tng+rZL24acmD+GA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.1.0.tgz", + "integrity": "sha512-7ZA0ssxfpuGXV8S5Ct1ZJoEL3sKg/8u7mIL4SK9/PwKBhxIq7K+FVcjnggNsuFnEhHTX+HYl9hHDrlX0odZnHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.1.0.tgz", + "integrity": "sha512-dI74Fzl03CrN2TEP7YVBtUBFgyRTRPyK1LMcIgAUfgW3o6f5SD1a8EsfT82+brpFOltjOhacIlFiYOkYtSI5Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.1.0.tgz", + "integrity": "sha512-SltrXXkEe9a+Jv8ConDkZW2Mdr0srpWurS7oYkYP1Mp07DNBpFyqawRnyo4p7ZvQU3e+R6irj/Fq0epCCvVUXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@napi-rs/keyring-freebsd-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", - "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.1.0.tgz", + "integrity": "sha512-l5wJQhqXkAfQ4FGxoRh0wMpL9VxVmfiYI5TTeOGNupuVsQvbHmysh8gfDQBBWVhyMENHJS2OqOSYIuqVjumAtA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.1.0.tgz", + "integrity": "sha512-3zYwO7dhJiUNyv2te81ujXm2vGxHwgx+z2eBm0dTYe3aWpvKQVzpfxCp/7cViJs8duVYE04wVOgXiRhRCMEq2w==", "cpu": [ "x64" ], @@ -1774,16 +2399,67 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", - "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@node-rs/crc32": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.8.tgz", + "integrity": "sha512-gOYKFwkojSdWrSmreCcGocRcAXSpRkNZYxv6nscmtddSWBKVGypdMbfLxu5qgmymdaCNApajuwZblYEfs7HswA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.8", + "@node-rs/crc32-android-arm64": "1.10.8", + "@node-rs/crc32-darwin-arm64": "1.10.8", + "@node-rs/crc32-darwin-x64": "1.10.8", + "@node-rs/crc32-freebsd-x64": "1.10.8", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.8", + "@node-rs/crc32-linux-arm64-gnu": "1.10.8", + "@node-rs/crc32-linux-arm64-musl": "1.10.8", + "@node-rs/crc32-linux-x64-gnu": "1.10.8", + "@node-rs/crc32-linux-x64-musl": "1.10.8", + "@node-rs/crc32-win32-arm64-msvc": "1.10.8", + "@node-rs/crc32-win32-ia32-msvc": "1.10.8", + "@node-rs/crc32-win32-x64-msvc": "1.10.8" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.8.tgz", + "integrity": "sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==", "cpu": [ "arm" ], @@ -1791,23 +2467,88 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-arm64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", - "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.8.tgz", + "integrity": "sha512-gvEOdU8tkSwK2McdlMeXDoyqtvJFPYShNopJtGaGMNoltN5EzFOsM/7XKF3rk9JLQ9GnZHQrCxur0Cksonow2A==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.8.tgz", + "integrity": "sha512-ylDspj/s3i+FCdTGoLZtOBsbR2PvAsI5taJDE5Pl7kDgeKX7IywdAYQtkh2orvTs0RqCjcnIBxrCDI/O/r+z1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.8.tgz", + "integrity": "sha512-DVTORLWomcx7F0CxQxlfBszAcMh8IkNGx/hgYHQ4iDnuf/uVjhs7l1hoVBghPymdFxP129+I/+b1WoQBbTJzaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.8.tgz", + "integrity": "sha512-ZzuW7RS9VFy7zD64YUFI2dK6tJrlAFfe1NBuNx/ouKZ6SK+NHW5F8hxZNrZCkv2XB7QyZk9wZ53oJ20PKylQTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.8.tgz", + "integrity": "sha512-5/2kW7V4KMV3o37ApvcnF4Pk11luzLnd38kQZowVCw5D/xGlCByEQOODfJHX9pv0C+4hp+8Dosul06RODjPEUw==", + "cpu": [ + "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1817,16 +2558,16 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-arm64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", - "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.8.tgz", + "integrity": "sha512-Vr1P3+WF+RsUVI8CNLiunl1vuzdpgTR1sf9PxgQFr3o//hh2KdqJeU4zZsFVTn+GbGZtqvRoba0wi8nKMzRLeg==", "cpu": [ "arm64" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, @@ -1837,16 +2578,16 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", - "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.8.tgz", + "integrity": "sha512-eS8YXANJLBqdOzf5jCNVEXiDI4wjXVqQybaJextJJINvHhnhhOXPar8402GMagewOxGxYsy4C69G8rBIzqL4cg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -1857,10 +2598,10 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-x64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", - "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.8.tgz", + "integrity": "sha512-DFd0eV47MAykz4PQDcBSlEvY3CtXqPt2gkBpvPngCpOUoIJgEk9tKtPjEgUwLatMkvsy4fEvAg7gckGickOyEw==", "cpu": [ "x64" ], @@ -1877,10 +2618,10 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-linux-x64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", - "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.8.tgz", + "integrity": "sha512-U7fP8FjDQuOb61YoT8EoWDIJ9x40ZrCzM6OcAH1wqYiu9Z94U14S+i8KwTC7YorotGtICdYZ/APn1eI8vNne5w==", "cpu": [ "x64" ], @@ -1897,10 +2638,10 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-win32-arm64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", - "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.8.tgz", + "integrity": "sha512-VJezyT9OWnMPnArDR5ok0ARLemiL83LLNYh4MC++oHoLegqzyBllqNlO8fVp2PmczGkn+rfKkZbtG4w3MQ6BbQ==", "cpu": [ "arm64" ], @@ -1914,10 +2655,10 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-win32-ia32-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", - "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "node_modules/@node-rs/crc32-win32-ia32-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.8.tgz", + "integrity": "sha512-01HooQK7WiG07/vvp0XzRnqSC5cvXvQ1MsQwlH9wwMWZ9qqHRO1j08cSeSt7DM4eALNisfX83bjqqrHzSKI9Iw==", "cpu": [ "ia32" ], @@ -1931,10 +2672,10 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/keyring-win32-x64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", - "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "node_modules/@node-rs/crc32-win32-x64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.8.tgz", + "integrity": "sha512-4O6G7yme7s98LUGwiqW7q2QqYmWD2IRQvV+q7+S4vjkUWiXUMbP8QEPYP9cF2WEQSdXCkyDEqDW10zeUdniPRA==", "cpu": [ "x64" ], @@ -1948,28 +2689,6 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2967,6 +3686,61 @@ "license": "MIT", "peer": true }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@secretlint/core": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", @@ -2983,6 +3757,49 @@ "node": ">=20.0.0" } }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@secretlint/profiler": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", @@ -2990,6 +3807,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, "node_modules/@secretlint/secretlint-rule-no-dotenv": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", @@ -3134,6 +3968,104 @@ } } }, + "node_modules/@textlint/ast-node-types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.8.0.tgz", + "integrity": "sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.8.0.tgz", + "integrity": "sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.8.0", + "@textlint/resolver": "15.8.0", + "@textlint/types": "15.8.0", + "debug": "^4.4.3", + "js-yaml": "^4.3.0", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=20.18.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.8.0.tgz", + "integrity": "sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.8.0.tgz", + "integrity": "sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.8.0.tgz", + "integrity": "sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -3256,6 +4188,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -3275,6 +4214,13 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3282,9 +4228,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.125.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", - "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", + "integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==", "dev": true, "license": "MIT" }, @@ -3773,95 +4719,342 @@ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "linux" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-darwin-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", - "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "linux" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-linux-arm": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", - "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", "cpu": [ - "arm" + "riscv64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-linux-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", - "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-linux-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", - "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-win32-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", - "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", "cpu": [ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@vscode/vsce-sign-win32-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", - "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@vscode/vsce/node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", + "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 10" + } }, "node_modules/acorn": { "version": "8.18.0", @@ -3913,6 +5106,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3992,6 +5201,13 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -4033,6 +5249,28 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.11.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", @@ -4072,6 +5310,42 @@ "url": "https://bevry.me/fund" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/boundary": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", @@ -4146,6 +5420,32 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -4498,6 +5798,103 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/cheerio/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4514,6 +5911,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4559,6 +5964,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4571,122 +5986,403 @@ "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=20" + "node": ">=20" + } + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-keychain": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cross-keychain/-/cross-keychain-1.1.0.tgz", + "integrity": "sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.6", + "meow": "^14.0.0" + }, + "bin": { + "cross-keychain": "dist/cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.2.0" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/cockatiel": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", - "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16" + "node": ">= 10" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=7.0.0" + "node": ">= 10" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colord": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", - "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "libc": [ + "glibc" + ], "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-js-compat": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", - "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "browserslist": "^4.28.7" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.4.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "node": ">= 10" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "node_modules/cross-keychain/node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">= 10" } }, "node_modules/cross-spawn": { @@ -4714,6 +6410,23 @@ "node": ">=12" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4735,6 +6448,19 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -4840,6 +6566,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4877,17 +6631,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.4.0" } }, "node_modules/dequal": { @@ -4918,7 +6718,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -4953,6 +6752,78 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dpdm": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.3.0.tgz", @@ -5145,6 +7016,44 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.25.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", @@ -5182,6 +7091,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5232,6 +7154,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -5530,6 +7468,17 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -5744,6 +7693,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5761,6 +7731,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formatly": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz", @@ -5778,6 +7765,14 @@ "node": ">=18.3.0" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", @@ -5904,6 +7899,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -5989,6 +7992,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "16.2.4", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", @@ -6074,6 +8094,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6087,6 +8120,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -6272,6 +8321,39 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6316,6 +8398,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/identifier-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.1.0.tgz", @@ -6332,6 +8431,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6400,6 +8521,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6467,6 +8601,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-ci/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -6568,6 +8722,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-it-type": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalthis": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7113,6 +9280,19 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -7172,6 +9352,16 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7488,6 +9678,26 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lint-staged": { "version": "17.5.1", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.5.1.tgz", @@ -7528,6 +9738,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7725,6 +9942,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8058,6 +10316,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -8693,6 +10958,29 @@ "node": ">=4" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -8706,6 +10994,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8732,6 +11034,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -8742,6 +11055,14 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/mocha": { "version": "11.8.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", @@ -9062,6 +11383,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9069,6 +11398,28 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.56", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", @@ -9079,6 +11430,55 @@ "node": ">=18" } }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -9152,6 +11552,19 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9165,6 +11578,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -9179,6 +11602,17 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -9263,40 +11697,188 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ovsx": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-1.2.0.tgz", + "integrity": "sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==", + "dev": true, + "license": "EPL-2.0", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@vscode/vsce": "^3.7.1", + "commander": "^6.2.1", + "cross-keychain": "^1.1.0", + "follow-redirects": "^1.16.0", + "is-ci": "^2.0.0", + "leven": "^3.1.0", + "semver": "^7.6.0", + "tmp": "^0.2.3", + "yauzl-promise": "^4.0.0" + }, + "bin": { + "ovsx": "bin/ovsx" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ovsx/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ovsx/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/ora/node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "node_modules/ovsx/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6" } }, - "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "node_modules/ovsx/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/ovsx/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, "node_modules/oxc-parser": { @@ -9399,6 +11981,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9484,6 +12079,26 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -9497,6 +12112,85 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9544,6 +12238,19 @@ "node": "20 || >=22" } }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -9584,6 +12291,19 @@ "node": ">=18" } }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -9671,6 +12391,35 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9761,6 +12510,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9771,6 +12532,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", @@ -9842,6 +12613,47 @@ "url": "https://github.com/sindresorhus/quote-js-string?sponsor=1" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -9925,6 +12737,57 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -10282,6 +13145,13 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -10289,28 +13159,107 @@ "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=11.0.0" + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/secretlint/node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "node_modules/secretlint/node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=v12.22.7" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "node_modules/secretlint/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/secretlint/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/semver": { "version": "7.8.5", @@ -10474,6 +13423,65 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-invariant": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", + "integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -10554,6 +13562,42 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -11035,6 +14079,111 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/terminal-link/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/terminal-link/node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -11050,6 +14199,13 @@ "node": "20 || >=22" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/textextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", @@ -11133,6 +14289,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11222,6 +14388,20 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11235,6 +14415,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -11285,6 +14478,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/unbash": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.11.tgz", @@ -11511,6 +14711,17 @@ "node": ">=10.12.0" } }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -11757,6 +14968,33 @@ "node": ">=20" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -11933,6 +15171,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/write-file-atomic": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", @@ -12120,6 +15366,21 @@ "node": ">=12" } }, + "node_modules/yauzl-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", + "integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@node-rs/crc32": "^1.7.0", + "is-it-type": "^5.1.2", + "simple-invariant": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/yazl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", @@ -12156,6 +15417,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 262c1f51..98251f0c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "url": "https://www.paypal.com/donate/?hosted_button_id=Q9VC7B42R7K82" }, "engines": { - "vscode": "^1.125.0", + "vscode": "^1.99.0", "node": ">=22" }, "categories": [ @@ -597,6 +597,7 @@ "harness:pseudo": "node scripts/pseudo-l10n.mjs", "test:a11y": "node scripts/a11y.mjs", "check:l10n": "node scripts/check-l10n.mjs", + "check:host-api": "node scripts/check-host-api.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "run-s lint:js lint:css lint:ps", @@ -610,7 +611,7 @@ "typecheck:e2e": "tsc -p test/e2e/tsconfig.json --noEmit", "typecheck:integration": "tsc -p test/integration/tsconfig.json --noEmit", "deadcode": "knip", - "cycles": "dpdm --no-warning --no-tree --exit-code circular:1 -T src/extension.ts src/host/backend/modelApiEntry.ts src/webview/main.tsx", + "cycles": "dpdm --no-warning --no-tree --exit-code circular:1 -T src/extension.ts src/host/backend/modelApiEntry.ts src/webview/main.tsx src/runtime/main.ts", "duplication": "jscpd", "test": "run-s test:unit test:integration", "test:unit": "vitest run --coverage", @@ -620,11 +621,12 @@ "test:integration:run": "vscode-test", "security:audit": "node scripts/audit.mjs", "security:secrets": "gitleaks git --redact --no-banner .", - "quality:gates": "run-s format:check lint typecheck check:l10n deadcode cycles duplication test:unit build security:audit", + "quality:gates": "run-s format:check lint typecheck check:l10n check:host-api deadcode cycles duplication test:unit build security:audit", "quality": "run-s quality:gates test:a11y security:secrets security:sast", "quality:ci": "run-s quality:gates test:a11y test:integration", "vscode:prepublish": "npm run build", "package": "vsce package --no-dependencies", + "package:acp": "node scripts/build.mjs --production && node scripts/package-acp.mjs", "images": "node scripts/render-images.mjs", "prepare": "husky", "security:sast": "node scripts/sast.mjs" @@ -641,8 +643,10 @@ "*.{json,jsonc,md,yml,yaml}": "prettier --write" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@eslint/js": "10.0.1", "@muse-code/sdk": "1.3.0", + "@napi-rs/keyring": "2.1.0", "@testing-library/dom": "10.4.2", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", @@ -650,7 +654,7 @@ "@types/node": "22.20.4", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@types/vscode": "1.125.0", + "@types/vscode": "1.99.0", "@types/vscode-webview": "1.57.5", "@vitest/coverage-v8": "5.0.1", "@vscode/test-cli": "0.0.15", @@ -668,6 +672,8 @@ "knip": "6.37.0", "lint-staged": "17.5.1", "npm-run-all2": "9.0.3", + "ovsx": "1.2.0", + "playwright-core": "1.63.0", "prettier": "3.9.8", "react": "19.3.0", "react-dom": "19.3.0", diff --git a/scripts/build.mjs b/scripts/build.mjs index b228509a..c9167ac7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -19,6 +19,14 @@ // dist/meta/ (M26, PLAN.md D29): the list of every source file that went in, // from which scripts/third-party-notices.mjs derives the packages whose // licences travel with the .vsix. The folder is not packaged. +// +// The ACP agent (`dist/acp.js`, PLAN.md D62) is built beside them for its own +// npm package, not the .vsix; its metafile goes to dist/meta-acp/ so the +// extension's notices never list what only the agent ships. Its keyring +// binding is a native module, installed with the package, never bundled. The +// agent loads the Model API backend from dist/modelApi.js, as the extension +// does, and its package ships that file (scripts/package-acp.mjs), so the +// backend is built once for both. import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs' import path from 'node:path' @@ -36,9 +44,15 @@ const SEARCH_WORKER_ENTRY = 'src/host/backend/searchWorker.ts' const SEARCH_WORKER_OUTFILE = 'dist/searchWorker.js' const WEBVIEW_ENTRY = 'src/webview/main.tsx' const WEBVIEW_OUTDIR = 'dist/webview' +const ACP_ENTRY = 'src/runtime/main.ts' +const ACP_OUTFILE = 'dist/acp.js' +const ACP_METAFILE_DIR = 'dist/meta-acp' const INTEGRATION_TEST_DIR = 'test/integration' const INTEGRATION_TEST_OUTDIR = 'dist/test/integration' -const NODE_TARGET = 'node22' +// The extension host of the oldest VS Code the manifest accepts: 1.99 runs +// Node 20.18 (PLAN.md M62). The ACP agent runs on the user's own Node 22. +const HOST_NODE_TARGET = 'node20.18' +const AGENT_NODE_TARGET = 'node22' const BROWSER_TARGET = 'chrome128' const BYTES_PER_KIB = 1024 const METAFILE_DIR = 'dist/meta' @@ -60,7 +74,7 @@ const hostOptions = { outfile: HOST_OUTFILE, platform: 'node', format: 'cjs', - target: NODE_TARGET, + target: HOST_NODE_TARGET, external: ['vscode'], } @@ -71,7 +85,7 @@ const modelApiOptions = { outfile: MODEL_API_OUTFILE, platform: 'node', format: 'cjs', - target: NODE_TARGET, + target: HOST_NODE_TARGET, } /** @type {import('esbuild').BuildOptions} */ @@ -81,7 +95,19 @@ const searchWorkerOptions = { outfile: SEARCH_WORKER_OUTFILE, platform: 'node', format: 'cjs', - target: NODE_TARGET, + target: HOST_NODE_TARGET, +} + +/** @type {import('esbuild').BuildOptions} */ +const acpOptions = { + ...common, + entryPoints: [ACP_ENTRY], + outfile: ACP_OUTFILE, + platform: 'node', + format: 'cjs', + target: AGENT_NODE_TARGET, + external: ['@napi-rs/keyring'], + banner: { js: '#!/usr/bin/env node' }, } /** @type {import('esbuild').BuildOptions} */ @@ -109,7 +135,7 @@ const integrationTestOptions = { outdir: INTEGRATION_TEST_OUTDIR, platform: 'node', format: 'cjs', - target: NODE_TARGET, + target: HOST_NODE_TARGET, external: ['vscode', 'mocha'], } @@ -134,7 +160,8 @@ if (isWatch) { searchWorker: esbuild.build(searchWorkerOptions), webview: esbuild.build(webviewOptions), } - const builds = Object.values(shipped) + const acp = esbuild.build(acpOptions) + const builds = [...Object.values(shipped), acp] if (!isProduction) { builds.push(esbuild.build(integrationTestOptions)) } @@ -145,6 +172,9 @@ if (isWatch) { const { metafile } = await build writeFileSync(path.join(METAFILE_DIR, `${name}.json`), JSON.stringify(metafile)) } + mkdirSync(ACP_METAFILE_DIR, { recursive: true }) + const { metafile } = await acp + writeFileSync(path.join(ACP_METAFILE_DIR, 'acp.json'), JSON.stringify(metafile)) } console.log('bundle sizes:') reportSize(HOST_OUTFILE) @@ -152,4 +182,5 @@ if (isWatch) { reportSize(SEARCH_WORKER_OUTFILE) reportSize(path.join(WEBVIEW_OUTDIR, 'main.js')) reportSize(path.join(WEBVIEW_OUTDIR, 'main.css')) + reportSize(ACP_OUTFILE) } diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index b4498099..2be250d5 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -17,6 +17,12 @@ const BUDGETS = [ { path: 'dist/modelApi.js', budgetKiB: 400 }, { path: 'dist/searchWorker.js', budgetKiB: 50 }, { path: 'dist/webview/main.js', budgetKiB: 900 }, + // The ACP agent (M63, PLAN.md D62), a process of its own installed once, + // never loaded by VS Code: the engine without the webview or the Model API + // backend (dist/modelApi.js, M57), plus the ACP SDK and the classic zod it + // imports (445.2 of 713.2 KiB when set, 257.6 of them zod's locales). The + // measured size plus about 15 %, rounded up to 50 KiB (D6 amendment). + { path: 'dist/acp.js', budgetKiB: 850 }, ] let hasFailure = false diff --git a/scripts/check-bundle-split.mjs b/scripts/check-bundle-split.mjs index c9746b33..4236b509 100644 --- a/scripts/check-bundle-split.mjs +++ b/scripts/check-bundle-split.mjs @@ -2,12 +2,14 @@ // The bundle-split gate (M57, PLAN.md D6), part of `npm run build`. The // Model API backend is a bundle of its own, dist/modelApi.js, which the // activation bundle (dist/extension.js) requires the first time that backend -// starts. The production build's metafiles (dist/meta/) name every source -// file in each bundle; this fails when: +// starts, and which the ACP agent (dist/acp.js, D62) loads the same way from +// its own package. The production build's metafiles (dist/meta/, +// dist/meta-acp/) name every source file in each bundle; this fails when: // // - a file of src/core/backends/modelapi/ is on neither list below, or on // both, or a listed file no longer exists (a new file needs a decision); -// - a LAZY_ONLY file, or the bundle's entry, is in dist/extension.js; +// - a LAZY_ONLY file, or the bundle's entry, is in dist/extension.js or in +// dist/acp.js (a second build of the backend); // - a LAZY_ONLY file is missing from dist/modelApi.js (the entry stopped // carrying the backend). // @@ -23,6 +25,7 @@ const ENTRY = 'src/host/backend/modelApiEntry.ts' const BUNDLES = { activation: { output: 'dist/extension.js', metafile: 'dist/meta/extension.json' }, modelApi: { output: 'dist/modelApi.js', metafile: 'dist/meta/modelApi.json' }, + acp: { output: 'dist/acp.js', metafile: 'dist/meta-acp/acp.json' }, } // The backend's files the activation bundle may carry, each with its reason. @@ -103,17 +106,27 @@ for (const name of [...ACTIVATION_ALLOWED.keys(), ...lazy]) { const activation = inputsOf(BUNDLES.activation) const modelApi = inputsOf(BUNDLES.modelApi) +const acp = inputsOf(BUNDLES.acp) +// The bundles that load the backend from dist/modelApi.js rather than carry it. +const loaders = [ + [BUNDLES.activation.output, activation], + [BUNDLES.acp.output, acp], +] for (const name of lazy) { const file = `${MODEL_API_DIR}/${name}` - if (activation.has(file)) { - problems.push(`${BUNDLES.activation.output} carries ${file}, which loads only with the backend`) + for (const [output, inputs] of loaders) { + if (inputs.has(file)) { + problems.push(`${output} carries ${file}, which loads only with the backend`) + } } if (!modelApi.has(file)) { problems.push(`${BUNDLES.modelApi.output} no longer carries ${file}`) } } -if (activation.has(ENTRY)) { - problems.push(`${BUNDLES.activation.output} carries the Model API bundle's entry, ${ENTRY}`) +for (const [output, inputs] of loaders) { + if (inputs.has(ENTRY)) { + problems.push(`${output} carries the Model API bundle's entry, ${ENTRY}`) + } } if (problems.length > 0) { @@ -137,6 +150,13 @@ for (const [input, bytes] of carried) { ` ${name} ${(bytes / BYTES_PER_KIB).toFixed(1)} KiB: ${ACTIVATION_ALLOWED.get(name) ?? ''}`, ) } +const acpCarried = acp + .keys() + .filter((input) => input.startsWith(`${MODEL_API_DIR}/`)) + .toArray() +console.log( + `ok ${BUNDLES.acp.output}: ${String(acpCarried.length)} of the backend's files, all on the allowed list`, +) console.log( `ok ${BUNDLES.modelApi.output}: carries the ${String(lazy.size)} files that load only with the backend`, ) diff --git a/scripts/check-host-api.mjs b/scripts/check-host-api.mjs new file mode 100644 index 00000000..43a231f2 --- /dev/null +++ b/scripts/check-host-api.mjs @@ -0,0 +1,716 @@ +#!/usr/bin/env node +// The host API record (M60, PLAN.md D60), part of `quality:gates`. +// +// Muse Spark Code is to run in hosts other than VS Code (D60): editors that +// implement the VS Code API themselves (Theia documents gaps and stubs), +// remote hosts, and adapters that load the engine with no `vscode` module +// at all. This gate keeps one record of what the extension asks of its +// host, so each target can be checked against it: +// +// - the manifest's facts (engines, extensionKind, entry points, +// capabilities, activation events, contribution points) and the build +// targets of the bundles; +// - every VS Code API the host code uses at run time, found with +// TypeScript's checker: a function, variable, class, enum or member +// declared in @types/vscode and used outside a type; the members of a +// VS Code interface the code implements (a provider's method, an options +// object's field), since the host calls or reads those; and the members +// of a VS Code object handed to code that takes it by shape (the log +// channel, SecretStorage), since that code calls them; with the files +// that use each; +// - the files that import `vscode`: the VS Code adapter; +// - the Node built-ins the host imports; +// - what the webview asks of its host: `acquireVsCodeApi`, and the +// `--vscode-*` theme variables its styles read. +// +// The record is docs/ide-compatibility/host-api.md, formatted as Prettier +// would. The check fails when the record differs from the source (a new +// API, a new file importing `vscode`, a theme variable): regenerate it +// with --write and review the diff, since each new entry is one more thing +// every target has to provide. +// +// Whatever the record says, the portable code never reaches `vscode` +// through its imports, type-only ones included, nor names one of the +// global types `@types/vscode` declares (`Thenable`), which needs no +// import: everything under PORTABLE_ROOTS and the host modules +// PORTABLE_HOST lists. That fails even after --write; the fix is in the +// code. +// +// node scripts/check-host-api.mjs check (quality:gates) +// node scripts/check-host-api.mjs --write regenerate the record + +import { builtinModules } from 'node:module' +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import * as prettier from 'prettier' +import ts from 'typescript' + +const RECORD = 'docs/ide-compatibility/host-api.md' +const MANIFEST = 'package.json' +const HOST_PROJECT = 'tsconfig.json' +const BUILD_SCRIPT = 'scripts/build.mjs' +const SOURCE_ROOT = 'src' +const WEBVIEW_ROOT = 'src/webview' +// Code other hosts load as it is: the engine, the protocol, the React app, +// and the ACP agent with its process (M63, D62), which run with no VS Code. +const PORTABLE_ROOTS = ['src/core', 'src/shared', 'src/webview', 'src/acp', 'src/runtime'] +// Host modules another adapter reuses (D60, M61): the conversation, both +// backends and their tool harness, the Model API backend's own bundle, which +// the ACP agent loads as the extension does (M57, D6), the credential store, +// the session store, the `ide` MCP server. +const PORTABLE_HOST = [ + 'src/host/auth/authService.ts', + 'src/host/auth/credentialStore.ts', + 'src/host/backend/fileSessionStore.ts', + 'src/host/backend/modelApiBackendManager.ts', + 'src/host/backend/modelApiEntry.ts', + 'src/host/backend/museCodeBackendManager.ts', + 'src/host/backend/toolIo.ts', + 'src/host/conversation/conversationController.ts', + 'src/host/ide/ideMcpServer.ts', +] +const VSCODE_MODULE = 'vscode' +const VSCODE_DECLARATIONS = '/node_modules/@types/vscode/' +const NODE_SCHEME = 'node:' +const WEBVIEW_HOST_CALL = 'acquireVsCodeApi' +const THEME_VARIABLE = /--vscode-[\w-]+/g +const SCRIPT_FILE = /\.tsx?$/ +const STYLE_FILE = /\.css$/ +const RESOLVABLE_SUFFIXES = ['', '.ts', '.tsx', '/index.ts', '/index.tsx'] +const BUILD_TARGET = /const (\w+)_TARGET = '([^']+)'/g +const WRITE_FLAG = '--write' +// A symbol that only names a place in the API (the `vscode` module, a +// namespace such as `window`): its members are the API. +const CONTAINER = ts.SymbolFlags.Module +// A symbol that qualifies a member (`Uri` in `Uri.file`): recorded only +// when it is used by itself (`new Uri`, `instanceof FileSystemError`). +const QUALIFIER = ts.SymbolFlags.Class | ts.SymbolFlags.Enum | ts.SymbolFlags.Interface + +/** Forward-slash path relative to the repository root. */ +function relative(fileName) { + return path.relative(process.cwd(), fileName).split(path.sep).join('/') +} + +/** Code-unit order: the same on every machine, whatever its locale data. */ +function byName(a, b) { + if (a === b) { + return 0 + } + return a < b ? -1 : 1 +} + +function listFiles(root, pattern) { + const found = [] + const entries = readdirSync(root, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(root, entry.name) + if (entry.isDirectory()) { + found.push(...listFiles(full, pattern)) + } else if (pattern.test(entry.name) && !entry.name.endsWith('.d.ts')) { + found.push(relative(full)) + } + } + return found.toSorted(byName) +} + +function addTo(map, key, file) { + const files = map.get(key) ?? new Set() + files.add(file) + map.set(key, files) +} + +// --- imports ----------------------------------------------------------------- + +/** Every module a file names: imports and re-exports (type-only too), `import()`, `require()`. */ +function moduleSpecifiers(sourceFile) { + const specifiers = [] + const visit = (node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text) + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + ts.isStringLiteral(node.moduleReference.expression) + ) { + specifiers.push(node.moduleReference.expression.text) + } else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + specifiers.push(node.argument.literal.text) + } else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0]) + ) { + specifiers.push(node.arguments[0].text) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return specifiers +} + +/** The source file a relative specifier names, or undefined for a stylesheet or JSON. */ +function resolveRelative(fromFile, specifier) { + const base = path.join(path.dirname(fromFile), specifier) + for (const suffix of RESOLVABLE_SUFFIXES) { + const candidate = `${base}${suffix}` + if (SCRIPT_FILE.test(candidate) && existsSync(candidate)) { + return relative(candidate) + } + } + if (existsSync(base)) { + return + } + throw new Error(`${fromFile}: cannot resolve "${specifier}"`) +} + +/** Each source file's relative imports (resolved) and package or built-in imports. */ +function importGraph(files) { + const graph = new Map() + for (const file of files) { + const text = readFileSync(file, 'utf8') + const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) + const local = new Set() + const external = new Set() + for (const specifier of moduleSpecifiers(sourceFile)) { + if (specifier.startsWith('.')) { + const target = resolveRelative(file, specifier) + if (target !== undefined) { + local.add(target) + } + } else { + external.add(specifier) + } + } + graph.set(file, { local, external, sourceFile }) + } + return graph +} + +/** The import chain from `root` to a file that imports `vscode`, or undefined. */ +function chainToVsCode(graph, root) { + const seen = new Set([root]) + const queue = [[root]] + while (queue.length > 0) { + const chain = queue.shift() + const node = graph.get(chain.at(-1)) + if (node === undefined) { + continue + } + if (node.external.has(VSCODE_MODULE)) { + return chain + } + for (const next of node.local) { + if (seen.has(next)) { + continue + } + seen.add(next) + queue.push([...chain, next]) + } + } +} + +function nodeBuiltin(specifier) { + const name = specifier.startsWith(NODE_SCHEME) ? specifier.slice(NODE_SCHEME.length) : specifier + return builtinModules.includes(name) ? `${NODE_SCHEME}${name}` : undefined +} + +// --- VS Code API uses ---------------------------------------------------------- + +function isVsCodeDeclaration(declaration) { + return declaration.getSourceFile().fileName.includes(VSCODE_DECLARATIONS) +} + +/** + * `window.showErrorMessage`, `Uri.file`, `Webview.postMessage`: the name + * from the declaration's containers. A field of an inline options type + * names its function and parameter: + * `window.createOutputChannel(options.log)`. + */ +function apiName(symbol) { + const declaration = symbol.declarations?.[0] + if ( + declaration === undefined || + !isVsCodeDeclaration(declaration) || + (symbol.flags & CONTAINER) !== 0 + ) { + return + } + let names = [symbol.name] + for (let node = declaration.parent; node !== undefined; node = node.parent) { + if ( + (ts.isModuleDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isPropertySignature(node) || + ts.isParameter(node)) && + node.name !== undefined && + ts.isIdentifier(node.name) + ) { + names.unshift(node.name.text) + } else if (ts.isFunctionLike(node) && node.name !== undefined && ts.isIdentifier(node.name)) { + names = [`${node.name.text}(${names.join('.')})`] + } + } + return names.join('.') +} + +function referencedSymbol(checker, identifier) { + const symbol = checker.getSymbolAtLocation(identifier) + return symbol === undefined || (symbol.flags & ts.SymbolFlags.Alias) === 0 + ? symbol + : checker.getAliasedSymbol(symbol) +} + +/** `Uri` in `vscode.Uri.file`: the qualifier of a longer name. */ +function isQualifier(identifier) { + const access = identifier.parent + if (!ts.isPropertyAccessExpression(access)) { + return false + } + return access.name === identifier + ? ts.isPropertyAccessExpression(access.parent) && access.parent.expression === access + : access.expression === identifier +} + +function memberName(member) { + return member.name !== undefined && + (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) + ? member.name.text + : undefined +} + +/** Members of a VS Code interface the code supplies: a class that implements it, an object literal typed by it. */ +function implementedMembers(checker, node, record) { + if (ts.isClassLike(node)) { + const clauses = node.heritageClauses ?? [] + for (const clause of clauses) { + if (clause.token !== ts.SyntaxKind.ImplementsKeyword) { + continue + } + for (const implemented of clause.types) { + const type = checker.getTypeAtLocation(implemented) + for (const member of node.members) { + const name = memberName(member) + const property = name === undefined ? undefined : checker.getPropertyOfType(type, name) + if (property !== undefined) { + record(apiName(property)) + } + } + } + } + } else if (ts.isObjectLiteralExpression(node)) { + const contextual = checker.getContextualType(node) + if (contextual === undefined) { + return + } + const type = checker.getNonNullableType(contextual) + for (const property of node.properties) { + const name = memberName(property) + const member = name === undefined ? undefined : checker.getPropertyOfType(type, name) + if (member !== undefined) { + record(apiName(member)) + } + } + } +} + +function isVsCodeType(type) { + const symbol = type.aliasSymbol ?? type.getSymbol() + const declaration = symbol?.declarations?.[0] + return declaration !== undefined && isVsCodeDeclaration(declaration) +} + +/** Where a value is handed to something typed by the receiver: an argument, a field, an initializer, a return. */ +function isHandedOver(node) { + const { parent } = node + return ( + ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && + (parent.arguments ?? []).includes(node)) || + ((ts.isPropertyAssignment(parent) || ts.isVariableDeclaration(parent)) && + parent.initializer === node) || + (ts.isShorthandPropertyAssignment(parent) && parent.name === node) || + (ts.isReturnStatement(parent) && parent.expression === node) || + (ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === node) + ) +} + +/** + * A VS Code object handed to code that takes it by shape (the log channel + * to `createLogger`, `SecretStorage` to the credential store): the members + * that shape names are used, though no VS Code type is in sight there. + */ +function handedOverMembers(checker, node, record) { + if (!ts.isExpression(node) || !isHandedOver(node)) { + return + } + const source = checker.getTypeAtLocation(node) + const target = checker.getContextualType(node) + if (target === undefined || !isVsCodeType(source) || isVsCodeType(target)) { + return + } + const properties = checker.getPropertiesOfType(checker.getNonNullableType(target)) + for (const property of properties) { + const member = checker.getPropertyOfType(source, property.name) + if (member !== undefined) { + record(apiName(member)) + } + } +} + +/** Every VS Code API the host's code uses at run time, with the files using it. */ +/** The host project's program (tsconfig.json), with the VS Code types it compiles against. */ +function hostProgram() { + const configPath = path.resolve(HOST_PROJECT) + const config = ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + }, + }, + ) + return ts.createProgram({ rootNames: config.fileNames, options: config.options }) +} + +/** + * A VS Code declaration a portable file names without importing it: the + * ambient globals `@types/vscode` declares (`Thenable`), which no import + * shows. Each as "file: name". + */ +function ambientVsCodeNames(program, portableFiles) { + const checker = program.getTypeChecker() + const found = new Set() + for (const sourceFile of program.getSourceFiles()) { + const file = relative(sourceFile.fileName) + if (!portableFiles.has(file)) { + continue + } + const visit = (node) => { + if (ts.isIdentifier(node)) { + const declaration = referencedSymbol(checker, node)?.declarations?.[0] + if (declaration !== undefined && isVsCodeDeclaration(declaration)) { + found.add(`${file}: ${node.text}`) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + } + return found +} + +function vsCodeApiUses(program, files) { + const checker = program.getTypeChecker() + const hostFiles = new Set(files) + const uses = new Map() + for (const sourceFile of program.getSourceFiles()) { + const file = relative(sourceFile.fileName) + if (!hostFiles.has(file)) { + continue + } + const record = (name) => { + if (name !== undefined) { + addTo(uses, name, file) + } + } + const visit = (node) => { + // Import lines and types are not run-time uses; a class's `extends` + // is, so its expression is still read. + if (ts.isImportDeclaration(node) || ts.isImportEqualsDeclaration(node)) { + return + } + if (ts.isHeritageClause(node) && node.token === ts.SyntaxKind.ExtendsKeyword) { + if (ts.isClassLike(node.parent)) { + for (const type of node.types) { + visit(type.expression) + } + } + return + } + if (ts.isTypeNode(node)) { + return + } + implementedMembers(checker, node, record) + handedOverMembers(checker, node, record) + if (ts.isIdentifier(node)) { + const symbol = referencedSymbol(checker, node) + if (symbol !== undefined && ((symbol.flags & QUALIFIER) === 0 || !isQualifier(node))) { + record(apiName(symbol)) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + } + return uses +} + +// --- the webview --------------------------------------------------------------- + +function webviewHostCalls(graph) { + const calls = new Map() + for (const [file, { sourceFile }] of graph) { + if (!file.startsWith(`${WEBVIEW_ROOT}/`)) { + continue + } + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === WEBVIEW_HOST_CALL) { + addTo(calls, WEBVIEW_HOST_CALL, file) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + } + return calls +} + +function themeVariables() { + const variables = new Map() + for (const file of [ + ...listFiles(WEBVIEW_ROOT, SCRIPT_FILE), + ...listFiles(WEBVIEW_ROOT, STYLE_FILE), + ]) { + for (const [variable] of readFileSync(file, 'utf8').matchAll(THEME_VARIABLE)) { + addTo(variables, variable, file) + } + } + return variables +} + +// --- the record ------------------------------------------------------------------ + +function code(text) { + return `\`${text}\`` +} + +function codeList(texts) { + return texts.map((text) => code(text)).join(', ') +} + +function fileList(files) { + return codeList(files.values().toArray().toSorted(byName)) +} + +function table(header, rows) { + return [ + `| ${header.join(' | ')} |`, + `| ${header.map(() => '---').join(' | ')} |`, + ...rows.map((row) => `| ${row.join(' | ')} |`), + ].join('\n') +} + +function sortedEntries(map) { + return map + .entries() + .toArray() + .toSorted(([a], [b]) => byName(a, b)) +} + +function manifestRows(manifest) { + const contributes = manifest.contributes ?? {} + const contributionPoints = Object.keys(contributes) + .toSorted(byName) + .map((point) => { + const value = contributes[point] + const size = Array.isArray(value) ? value.length : Object.keys(value).length + return `${code(point)} (${String(size)})` + }) + const capabilities = manifest.capabilities ?? {} + return [ + ['`engines.vscode`', code(manifest.engines.vscode)], + ['`engines.node`', code(manifest.engines.node)], + ['`main`', manifest.main === undefined ? 'none' : code(manifest.main)], + ['`browser`', manifest.browser === undefined ? 'none' : code(manifest.browser)], + ['`extensionKind`', codeList(manifest.extensionKind ?? [])], + [ + '`capabilities.virtualWorkspaces`', + code(String(capabilities.virtualWorkspaces?.supported ?? 'unset')), + ], + [ + '`capabilities.untrustedWorkspaces`', + code(String(capabilities.untrustedWorkspaces?.supported ?? 'unset')), + ], + ['`enabledApiProposals`', codeList(manifest.enabledApiProposals ?? []) || 'none'], + ['`activationEvents`', codeList(manifest.activationEvents ?? []) || 'none'], + ['`contributes`', contributionPoints.join(', ')], + ] +} + +function buildTargets() { + const text = readFileSync(BUILD_SCRIPT, 'utf8') + const targets = text + .matchAll(BUILD_TARGET) + .map(([, name, target]) => [code(name.toLowerCase()), code(target)]) + .toArray() + if (targets.length === 0) { + throw new Error(`${BUILD_SCRIPT}: no *_TARGET constant found`) + } + return targets +} + +function renderRecord({ manifest, apis, adapterFiles, builtins, hostCalls, variables }) { + const adapterRows = adapterFiles + .entries() + .map(([file, count]) => [code(file), String(count)]) + .toArray() + return [ + '# Host API record', + '', + 'What Muse Spark Code asks of its host (PLAN.md D60, M60). Generated by', + '`node scripts/check-host-api.mjs --write`; `npm run check:host-api` fails when it', + 'differs from the source. Do not edit it by hand.', + '', + '## Manifest', + '', + table(['Field', 'Value'], manifestRows(manifest)), + '', + '## Build targets', + '', + table(['Bundle', 'esbuild target'], buildTargets()), + '', + '## Files that import `vscode`', + '', + `The VS Code adapter: ${String(adapterFiles.size)} files. Everything else reaches VS Code only through them.`, + '', + table(['File', 'VS Code APIs used'], adapterRows), + '', + '## Portable modules', + '', + 'These never reach `vscode` through their imports, type-only ones included; the gate fails if one does:', + '', + ...PORTABLE_ROOTS.map((root) => `- everything under ${code(`${root}/`)}`), + ...PORTABLE_HOST.map((file) => `- ${code(file)}`), + '', + `## VS Code API used at run time (${String(apis.size)})`, + '', + 'Functions, variables, classes, enums and members declared in `@types/vscode`; the members of a VS Code interface the code implements (a provider, an options object); and the members of a VS Code object handed to code that takes it by shape.', + '', + table( + ['API', 'Files'], + sortedEntries(apis).map(([name, files]) => [code(name), fileList(files)]), + ), + '', + `## Node built-ins the host imports (${String(builtins.size)})`, + '', + table( + ['Module', 'Files'], + sortedEntries(builtins).map(([name, files]) => [code(name), String(files.size)]), + ), + '', + "## The webview's host", + '', + table( + ['Call', 'Files'], + sortedEntries(hostCalls).map(([name, files]) => [code(name), fileList(files)]), + ), + '', + `Theme variables the styles read (${String(variables.size)}), from ${fileList(new Set(variables.values().flatMap((files) => files.values())))}:`, + '', + sortedEntries(variables) + .map(([name]) => code(name)) + .join(', '), + '', + ].join('\n') +} + +// --- main -------------------------------------------------------------------------- + +const sourceFiles = listFiles(SOURCE_ROOT, SCRIPT_FILE) +const graph = importGraph(sourceFiles) +const hostFiles = sourceFiles.filter((file) => !file.startsWith(`${WEBVIEW_ROOT}/`)) + +const problems = [] +const portable = [ + ...sourceFiles.filter((file) => PORTABLE_ROOTS.some((root) => file.startsWith(`${root}/`))), + ...PORTABLE_HOST, +] +for (const file of portable) { + if (!graph.has(file)) { + problems.push(`${file}: listed as portable but not found`) + continue + } + const chain = chainToVsCode(graph, file) + if (chain !== undefined) { + problems.push(`${file} reaches \`vscode\`: ${[...chain, VSCODE_MODULE].join(' -> ')}`) + } +} + +const program = hostProgram() +const ambientUses = ambientVsCodeNames(program, new Set(portable)) +for (const use of ambientUses) { + problems.push(`${use} is a VS Code type, which portable code does not use`) +} +const apis = vsCodeApiUses(program, hostFiles) +const apisPerFile = new Map() +for (const files of apis.values()) { + for (const file of files) { + apisPerFile.set(file, (apisPerFile.get(file) ?? 0) + 1) + } +} +const adapterFiles = new Map( + hostFiles + .filter((file) => graph.get(file)?.external.has(VSCODE_MODULE)) + .map((file) => [file, apisPerFile.get(file) ?? 0]), +) +const builtins = new Map() +for (const file of hostFiles) { + const specifiers = graph.get(file)?.external ?? [] + for (const specifier of specifiers) { + const builtin = nodeBuiltin(specifier) + if (builtin !== undefined) { + addTo(builtins, builtin, file) + } + } +} +const hostCalls = webviewHostCalls(graph) +const variables = themeVariables() +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) + +const rendered = renderRecord({ manifest, apis, adapterFiles, builtins, hostCalls, variables }) +const prettierOptions = { ...(await prettier.resolveConfig(RECORD)), filepath: RECORD } +const record = await prettier.format(rendered, prettierOptions) +const summary = `${String(apis.size)} VS Code APIs, ${String(adapterFiles.size)} files importing vscode, ${String(builtins.size)} Node built-ins, ${String(variables.size)} theme variables` + +if (process.argv.includes(WRITE_FLAG)) { + writeFileSync(RECORD, record) + console.log(`host-api: wrote ${RECORD} (${summary})`) +} else { + const current = existsSync(RECORD) ? readFileSync(RECORD, 'utf8') : '' + if (current !== record) { + const before = new Set(current.split('\n')) + const after = new Set(record.split('\n')) + const removed = [...before.difference(after)] + const added = [...after.difference(before)] + problems.push( + [ + `${RECORD} is not what the source gives; run \`npm run check:host-api -- --write\` and review the diff:`, + ...removed.map((line) => ` - ${line}`), + ...added.map((line) => ` + ${line}`), + ].join('\n'), + ) + } +} + +for (const problem of problems) { + console.error(`FAIL ${problem}`) +} +console.log(`host-api: ${summary}; ${String(problems.length)} problem(s)`) +if (problems.length > 0) { + process.exit(1) +} diff --git a/scripts/lib/vscode-engine.mjs b/scripts/lib/vscode-engine.mjs index 3934f30c..f3b3c00d 100644 --- a/scripts/lib/vscode-engine.mjs +++ b/scripts/lib/vscode-engine.mjs @@ -1,4 +1,4 @@ -// The oldest VS Code the manifest accepts (`engines.vscode`, "^1.125.0"), +// The oldest VS Code the manifest accepts (`engines.vscode`, "^1.99.0"), // for the integration tests' second run (.vscode-test.mjs) and the CI cache // key (scripts/vscode-versions.mjs). Read from package.json so the tested // floor moves with the declared one (M26, PLAN.md D29). diff --git a/scripts/package-acp.mjs b/scripts/package-acp.mjs new file mode 100644 index 00000000..186b6b78 --- /dev/null +++ b/scripts/package-acp.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// The ACP agent's npm package (M63, PLAN.md D62): `muse-spark-code-acp`, +// laid out in dist/acp-package/ and packed with `npm pack` into +// dist/muse-spark-code-acp-.tgz, which each GitHub Release carries +// and `npm install -g` installs. The bundles come from the production build: +// the agent, the Model API backend it loads when that backend first starts +// (the extension's own dist/modelApi.js, M57, PLAN.md D6), and the search +// worker; +// the package's manifest is written here, with the extension's version, the +// agent's command and the one dependency it does not bundle, the native +// keyring binding (D61), at the version this repository locks. +// +// node scripts/build.mjs --production && node scripts/package-acp.mjs +// (npm run package:acp) + +import { execFileSync } from 'node:child_process' +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const STAGE = path.join('dist', 'acp-package') +const BUNDLES = ['acp.js', 'modelApi.js', 'searchWorker.js'] +// The C# of the shell tool's Windows job (M27), compiled on first use, as +// the extension ships it (PLAN.md D6): its own file and the half it shares. +const JOB_SOURCES = [ + path.join('native', 'windows', 'MuseSparkJob.cs'), + path.join('native', 'windows', 'MuseSparkMcpJob.cs'), +] +const NATIVE_DEPENDENCY = '@napi-rs/keyring' +const PACKAGE_NAME = 'muse-spark-code-acp' +const README = path.join('docs', 'acp.md') +const NOTICES = 'THIRD_PARTY_NOTICES.txt' + +/** The keyring binding's version, as this repository locks it. */ +function lockedVersion(manifest) { + const version = manifest.devDependencies?.[NATIVE_DEPENDENCY] + if (typeof version !== 'string') { + throw new TypeError(`package.json does not lock ${NATIVE_DEPENDENCY}`) + } + return version +} + +function requireBundles() { + const missing = BUNDLES.find((bundle) => !existsSync(path.join('dist', bundle))) + if (missing !== undefined) { + throw new Error(`dist/${missing} is missing: run "node scripts/build.mjs --production" first`) + } +} + +const manifest = JSON.parse(readFileSync('package.json', 'utf8')) +const keyringVersion = lockedVersion(manifest) +requireBundles() + +rmSync(STAGE, { recursive: true, force: true }) +mkdirSync(path.join(STAGE, 'dist'), { recursive: true }) +for (const bundle of BUNDLES) { + copyFileSync(path.join('dist', bundle), path.join(STAGE, 'dist', bundle)) +} +for (const source of JOB_SOURCES) { + mkdirSync(path.join(STAGE, path.dirname(source)), { recursive: true }) + copyFileSync(source, path.join(STAGE, source)) +} +cpSync('l10n', path.join(STAGE, 'l10n'), { + recursive: true, + filter: (source) => !source.endsWith('untranslated.json'), +}) +copyFileSync('LICENSE', path.join(STAGE, 'LICENSE')) +copyFileSync(README, path.join(STAGE, 'README.md')) +execFileSync( + process.execPath, + ['scripts/third-party-notices.mjs', '--acp', path.join(STAGE, NOTICES)], + { stdio: 'inherit' }, +) + +const agentManifest = { + name: PACKAGE_NAME, + version: manifest.version, + description: + 'Muse Spark Code (Unofficial) for editors that speak the Agent Client Protocol: Zed, JetBrains IDEs, Xcode, Neovim, Emacs and more. Not endorsed by Meta.', + license: manifest.license, + homepage: `${manifest.repository.url.replace(/\.git$/, '')}/blob/main/docs/acp.md`, + repository: manifest.repository, + bugs: manifest.bugs, + keywords: ['muse spark', 'muse code', 'agent client protocol', 'acp', 'coding agent'], + bin: { [PACKAGE_NAME]: 'dist/acp.js' }, + files: ['dist', 'native', 'l10n', 'README.md', 'LICENSE', NOTICES], + engines: { node: manifest.engines.node }, + dependencies: { [NATIVE_DEPENDENCY]: keyringVersion }, +} +writeFileSync(path.join(STAGE, 'package.json'), `${JSON.stringify(agentManifest, null, 2)}\n`) + +const packed = execFileSync('npm', ['pack', '--pack-destination', '..'], { + cwd: path.resolve(STAGE), + encoding: 'utf8', + shell: process.platform === 'win32', +}) + .trim() + .split('\n') + .at(-1) +console.log(`dist/${String(packed)}: ${PACKAGE_NAME} ${String(manifest.version)}`) diff --git a/scripts/third-party-notices.mjs b/scripts/third-party-notices.mjs index 912c7562..bb614e72 100644 --- a/scripts/third-party-notices.mjs +++ b/scripts/third-party-notices.mjs @@ -11,6 +11,11 @@ // node scripts/third-party-notices.mjs check (the build runs this): // exit 1 when the file is stale // node scripts/third-party-notices.mjs --write regenerate it (npm run notices) +// node scripts/third-party-notices.mjs --acp +// the ACP agent's package (M63, +// PLAN.md D62): its three bundles' +// packages, written to +// by scripts/package-acp.mjs // // Versions are left out on purpose: a routine version bump changes no // licence and passes, while a package that enters a bundle, or a licence @@ -22,6 +27,14 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' const METAFILE_DIR = path.join('dist', 'meta') +// The ACP agent ships acp.js, the Model API backend's bundle it loads (M57) +// and the search worker (scripts/build.mjs, scripts/package-acp.mjs). +const ACP_METAFILES = [ + path.join('dist', 'meta-acp', 'acp.json'), + path.join(METAFILE_DIR, 'modelApi.json'), + path.join(METAFILE_DIR, 'searchWorker.json'), +] +const ACP_FLAG = '--acp' const NODE_MODULES = 'node_modules/' const LICENCE_FILE = /^(licen[cs]e|copying)(\.(md|txt|markdown))?$/i const NOTICE_FILE = /^notice(\.(md|txt))?$/i @@ -51,6 +64,18 @@ Generated from the production build by scripts/third-party-notices.mjs; "npm run notices" regenerates this file. ` +const ACP_HEADER = `THIRD-PARTY SOFTWARE NOTICES +muse-spark-code-acp, Muse Spark Code (Unofficial) for editors that speak the +Agent Client Protocol + +The agent's bundles (dist/acp.js, dist/modelApi.js and +dist/searchWorker.js) include code from the packages below, each under its +own licence, reproduced here as the package ships it. The keyring binding (@napi-rs/keyring) is installed +beside it as a dependency, with its own licence. + +Generated from the production build by scripts/third-party-notices.mjs. +` + /** The package directory of an esbuild input under node_modules (the innermost one). */ function packageDirOf(input) { const at = input.lastIndexOf(NODE_MODULES) + NODE_MODULES.length @@ -59,13 +84,14 @@ function packageDirOf(input) { return input.slice(0, at) + packageName } -function shippedPackageDirs() { - if (!existsSync(METAFILE_DIR)) { - throw new Error(`${METAFILE_DIR} is missing: run "node scripts/build.mjs --production" first`) +function shippedPackageDirs(metafiles) { + const missing = metafiles.find((file) => !existsSync(file)) + if (missing !== undefined) { + throw new Error(`${missing} is missing: run "node scripts/build.mjs --production" first`) } const dirs = new Set() - for (const file of readdirSync(METAFILE_DIR)) { - const metafile = JSON.parse(readFileSync(path.join(METAFILE_DIR, file), 'utf8')) + for (const file of metafiles) { + const metafile = JSON.parse(readFileSync(file, 'utf8')) for (const output of Object.values(metafile.outputs)) { const packageInputs = Object.keys(output.inputs).filter((input) => input.includes(NODE_MODULES), @@ -125,18 +151,37 @@ function describePackage(dir, problems) { } /** One block per distinct licence text, naming every package that ships it. */ -function render(packages) { +function render(packages, isAcp) { + const header = isAcp ? ACP_HEADER : HEADER const sorted = packages.toSorted((a, b) => a.name.localeCompare(b.name, 'en')) const groups = Map.groupBy(sorted, (entry) => entry.text) const blocks = [...groups].map(([text, group]) => { const names = group.map((entry) => `${entry.name} (${entry.licence})\n ${entry.url}`) return `${RULE}\n${names.join('\n')}\n${THIN_RULE}\n\n${text}\n` }) - return `${HEADER}\n${blocks.join('\n')}` + return `${header}\n${blocks.join('\n')}` +} + +/** The file `--acp` names; undefined without the flag. */ +function acpOutputFile() { + const index = process.argv.indexOf(ACP_FLAG) + if (index === -1) { + return + } + const file = process.argv[index + 1] + if (file === undefined) { + throw new Error(`${ACP_FLAG} needs the file to write`) + } + return file } +const acpOutput = acpOutputFile() +const metafiles = + acpOutput === undefined + ? readdirSync(METAFILE_DIR).map((file) => path.join(METAFILE_DIR, file)) + : ACP_METAFILES const problems = [] -const packages = shippedPackageDirs().map((dir) => describePackage(dir, problems)) +const packages = shippedPackageDirs(metafiles).map((dir) => describePackage(dir, problems)) if (problems.length > 0) { console.error(`third-party notices: ${String(problems.length)} package(s) need a review:`) for (const problem of problems) { @@ -145,9 +190,12 @@ if (problems.length > 0) { process.exit(1) } const OUTPUT = 'THIRD_PARTY_NOTICES.txt' -const expected = render(packages) +const expected = render(packages, acpOutput !== undefined) -if (process.argv.includes('--write')) { +if (acpOutput !== undefined) { + writeFileSync(acpOutput, expected) + console.log(`${acpOutput}: ${String(packages.length)} packages written`) +} else if (process.argv.includes('--write')) { writeFileSync(OUTPUT, expected) console.log(`${OUTPUT}: ${String(packages.length)} packages written`) } else { diff --git a/src/acp/agent.ts b/src/acp/agent.ts new file mode 100644 index 00000000..d3c1a135 --- /dev/null +++ b/src/acp/agent.ts @@ -0,0 +1,1241 @@ +// The Muse Spark agent over the Agent Client Protocol (PLAN.md D62): one +// client on stdio, any number of sessions, each an AgentSession of the +// backend chosen at launch. The client's editor shows the chat, the tool +// calls, the plan and the permission prompts; the backend runs the tools. +// +// What the panel guarantees holds here too: an approval is decided only +// with a choice the backend offered, and one the client did not answer is +// denied (D62); "Edit automatically" answers only what the panel's rule +// allows (`editAutomaticallyChoice`, D24); a paid feature is on only with +// its flag, and each use asks in the editor first, naming its price (M58, +// paid.ts). Every update of a turn goes out before the turn's response. + +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { + agent as acpAgent, + type AgentApp, + type AgentContext, + type AuthMethod, + type ClientCapabilities, + type ContentBlock, + type CreateElicitationRequest, + type InitializeResponse, + type ListSessionsResponse, + type McpServer, + PROTOCOL_VERSION, + RequestError, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionModeState, + type SessionUpdate, + type StopReason, +} from '@agentclientprotocol/sdk' +import { + type AgentHost, + type AgentSession, + isPromptSettledError, + type ModelSummary, + type SessionMcpServer, + type SkillSummary, + type TurnPart, +} from '../core/agent/agentBackend' +import { editAutomaticallyChoice } from '../core/agent/approvalRules' +import { failureForLog } from '../core/backends/musecode/logText' +import type { CoreLogger } from '../core/logging' +import { type PaidUseAnswer, paidUseQuestion } from '../core/paid/paidConsent' +import type { AgentEvent } from '../shared/agentEvents' +import { + ACP_AGENT_NAME, + ACP_AGENT_TITLE, + ACP_CONFIG_IDS, + ACP_PAID_TOOL_CALL_PREFIX, + ACP_SESSION_LIST_LIMIT, + type AcpBackendKind, + CONTRIBUTOR_MODEL_SUFFIX, + DEFAULT_EFFORT, + type EffortLevel, + MSP_REQUESTED_CAPABILITIES, + type PermissionMode, + UI_TEXT, +} from '../shared/constants' +import { effortForThinking, effortLabel, effortLevelsFor, isEffortLevel } from '../shared/effort' +import { fill } from '../shared/l10n/text' +import { parseSkillInvocation } from '../shared/mentions' +import type { PaidUseRequest } from '../shared/paid' +import { + approvalModeFor, + availablePermissionModes, + permissionModeDetail, +} from '../shared/permissionModes' +import { type AcpPaidUse, paidUseAnswer, paidUseOptions } from './paid' +import { formAnswers, questionForm, questionsText } from './questions' +import { + approvalToolCall, + decidedChoice, + mcpServersFrom, + permissionOptions, + permissionResponse, + planEntries, + promptParts, + UpdateTranslator, +} from './translate' + +// Muse Code runs a session's MCP servers only with this grant (M63c). +const [SESSION_MCP_CAPABILITY] = MSP_REQUESTED_CAPABILITIES + +/** Whether the backend can start a session now, and what the user must do if not. */ +export type BackendReadiness = + | { readonly state: 'ready' } + | { readonly state: 'signedOut'; readonly message: string } + | { readonly state: 'unavailable'; readonly message: string } + +/** The backend the agent was started on (the runtime builds it, D62). */ +export interface AcpBackend { + readonly kind: AcpBackendKind + /** `isRecheck`: the user says they signed in, so nothing said before counts (`authenticate`). */ + readonly readiness: (isRecheck: boolean) => Promise + /** The host for a folder, started on first use. */ + readonly hostFor: (cwd: string) => Promise +} + +export interface AcpAgentOptions { + /** Bypass permissions is offered (`--allow-dangerously-skip-permissions`). */ + readonly canBypass: boolean + /** Contributor-tier models are listed (`--allow-contributor-models`). */ + readonly allowsContributorModels: boolean + readonly initialMode: PermissionMode +} + +/** How the user signs in to the chosen backend (D61, D62). */ +export interface SignInMethod { + readonly id: string + readonly name: string + readonly description: string + /** Appended to the agent's configured command by a client that runs terminal sign-ins. */ + readonly args: readonly string[] + /** The command a user runs by hand where the client cannot (`muse-spark-code-acp auth set`). */ + readonly command: string +} + +export interface AcpAgentDeps { + readonly backend: AcpBackend + readonly version: string + readonly options: AcpAgentOptions + readonly signIn: SignInMethod + /** The folder a `session/list` without one lists (the agent's own). */ + readonly defaultCwd: string + /** The flagged paid features; the agent asks before each use (M63c, M58). */ + readonly paid: AcpPaidUse + readonly log: CoreLogger +} + +type ApprovalRequest = Extract +type QuestionRequest = Extract +type TurnCompleted = Extract + +interface PendingPrompt { + readonly resolve: (reason: StopReason) => void + readonly reject: (error: unknown) => void + turnId: string | undefined + isCancelled: boolean +} + +type PromptOutcome = { readonly reason: StopReason } | { readonly error: unknown } + +interface PreparingPrompt { + isCancelled: boolean + error?: unknown +} + +/** Identity of the latest load or resume; kept while earlier releases finish. */ +interface SessionClaim { + readonly sessionId: string +} + +const CANCELLED_TERMINAL = 'cancelled' +const FAILED_TERMINAL = 'failed' +// Turns that finished before `sendTurn` answered with their id; a few suffice. +const EARLY_FINISHES_KEPT = 8 + +function isContributorModel(modelId: string): boolean { + return modelId.endsWith(CONTRIBUTOR_MODEL_SUFFIX) +} + +/** The model a new session starts on: the backend's default, else the first listed. */ +function startingModel(models: readonly ModelSummary[]): string { + const model = models.find((candidate) => candidate.isDefault) ?? models[0] + if (model === undefined) { + throw RequestError.internalError(undefined, UI_TEXT.acpNoModels) + } + return model.modelId +} + +/** The default effort where the model serves it, else the nearest tier it has. */ +function servedEffort(modelId: string, wanted: EffortLevel): EffortLevel { + const levels = effortLevelsFor(modelId) + return levels.includes(wanted) ? wanted : (levels.at(-1) ?? DEFAULT_EFFORT) +} + +/** One ACP session over one AgentSession. */ +class AcpSession { + private readonly translator: UpdateTranslator + private readonly unsubscribe: () => void + private readonly approvals = new Map() + private readonly earlyFinishes = new Map() + private outbox: Promise = Promise.resolve() + private pending: PendingPrompt | undefined + /** + * A prompt before its turn starts, while the session's skills are first + * announced: the session is busy, and a cancel ends the prompt there. + */ + private preparing: PreparingPrompt | undefined + private skills: readonly SkillSummary[] = [] + private areCommandsAnnounced = false + private effort: EffortLevel = DEFAULT_EFFORT + /** Let go (closed, loaded again, or never set up): the editor's late answers decide nothing. */ + private isDisposed = false + /** A turn being started (`sendTurn` not yet answered): a release waits for it, then stops it. */ + private starting: Promise | undefined + public readonly sessionId: string + + public constructor( + public readonly session: AgentSession, + public readonly host: AgentHost, + private readonly cwd: string, + private readonly client: AgentContext, + private readonly clientCapabilities: ClientCapabilities, + private readonly models: readonly ModelSummary[], + private readonly deps: AcpAgentDeps, + private mode: PermissionMode, + private modelId: string, + ) { + this.sessionId = session.sessionId + this.translator = new UpdateTranslator(cwd, false) + this.unsubscribe = session.onEvent((event) => { + this.onEvent(event) + }) + } + + /** Queues an update behind the ones before it: the client sees them in order. */ + private send(update: SessionUpdate): void { + this.outbox = this.deliver(this.outbox, update) + } + + private async deliver(previous: Promise, update: SessionUpdate): Promise { + await previous + if (this.isDisposed) { + // Let go: nothing more reaches the editor for it, not even history + // a load queued before a close or a newer load. + return + } + try { + await this.client.notify('session/update', { sessionId: this.sessionId, update }) + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: an update was not sent: ${failureForLog(error)}`, + ) + } + } + + /** `/selector arguments` naming one of the session's skills runs that skill, as in the panel. */ + private withSkill(parts: TurnPart[]): TurnPart[] { + const [first, ...rest] = parts + if (first?.type !== 'text') { + return parts + } + const selectors = new Set(this.skills.map((skill) => skill.selector)) + const invocation = parseSkillInvocation(first.text, selectors) + if (invocation === undefined) { + return parts + } + const skill: TurnPart = + invocation.arguments === undefined + ? { type: 'skill', selector: invocation.selector } + : { type: 'skill', selector: invocation.selector, arguments: invocation.arguments } + return [skill, ...rest] + } + + private async announceCommands(): Promise { + if (this.areCommandsAnnounced) { + return + } + this.areCommandsAnnounced = true + await this.refreshCommands() + } + + private async refreshCommands(): Promise { + try { + this.skills = await this.session.listSkills() + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: skills unavailable: ${failureForLog(error)}`, + ) + return + } + this.send({ + sessionUpdate: 'available_commands_update', + availableCommands: this.skills.map((skill) => ({ + name: skill.selector, + description: skill.description === '' ? skill.displayName : skill.description, + input: skill.argumentHint === undefined ? null : { hint: skill.argumentHint }, + })), + }) + } + + private onEvent(event: AgentEvent): void { + switch (event.type) { + case 'approvalRequested': { + this.approvals.set(event.approvalId, event) + void this.askPermission(event) + return + } + case 'approvalUpdated': { + const first = this.approvals.get(event.approvalId) + if (first !== undefined) { + const next: ApprovalRequest = { + ...first, + requirementId: event.requirementId, + subject: event.subject, + availableChoices: event.availableChoices, + } + this.approvals.set(event.approvalId, next) + void this.askPermission(next) + } + return + } + case 'approvalResolved': { + this.approvals.delete(event.approvalId) + return + } + case 'questionRequested': { + void this.ask(event) + return + } + case 'turnCompleted': { + this.finishTurn(event) + return + } + case 'skillsChanged': { + void this.refreshCommands() + return + } + case 'modelChanged': { + this.modelId = event.modelId + this.send({ sessionUpdate: 'config_option_update', configOptions: this.configOptions() }) + return + } + case 'effortChanged': { + if (isEffortLevel(event.effort)) { + this.effort = event.effort + this.send({ sessionUpdate: 'config_option_update', configOptions: this.configOptions() }) + } + return + } + default: { + for (const update of this.translator.updates(event)) { + this.send(update) + } + } + } + } + + private noteTurnId(turnId: string): void { + const pending = this.pending + if (pending === undefined) { + return + } + pending.turnId = turnId + const early = this.earlyFinishes.get(turnId) + if (early === undefined) { + return + } + this.earlyFinishes.delete(turnId) + this.settle(pending, early) + } + + private finishTurn(event: TurnCompleted): void { + const pending = this.pending + if (pending?.turnId === event.turnId) { + this.settle(pending, event) + return + } + if (pending?.turnId !== undefined) { + return + } + this.earlyFinishes.set(event.turnId, event) + const [oldest] = this.earlyFinishes.keys() + if (oldest !== undefined && this.earlyFinishes.size > EARLY_FINISHES_KEPT) { + this.earlyFinishes.delete(oldest) + } + } + + /** ACP's answer to the prompt: cancelled whenever the client cancelled it (as the spec requires). */ + private settle(pending: PendingPrompt, event: TurnCompleted): void { + this.pending = undefined + if (pending.isCancelled || event.terminal === CANCELLED_TERMINAL) { + pending.resolve('cancelled') + return + } + if (event.terminal === FAILED_TERMINAL) { + pending.reject( + RequestError.internalError(undefined, event.reason ?? event.errorKind ?? event.terminal), + ) + return + } + pending.resolve('end_turn') + } + + private async askPermission(event: ApprovalRequest): Promise { + const pending = this.pending + let choice = editAutomaticallyChoice(event, this.mode) + if (choice === undefined) { + let response: RequestPermissionResponse | undefined + try { + // The tool call the request names has gone out first. + await this.outbox + if (!this.isCurrentPrompt(pending) || this.approvals.get(event.approvalId) !== event) { + return + } + response = permissionResponse( + await this.client.request('session/request_permission', { + sessionId: this.sessionId, + toolCall: approvalToolCall(event, this.cwd), + options: permissionOptions(event.availableChoices), + }), + ) + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: permission request failed, denying: ${failureForLog(error)}`, + ) + } + choice = decidedChoice(response, event.availableChoices) + } + if (!this.isCurrentPrompt(pending) || this.approvals.get(event.approvalId) !== event) { + // Let go while the editor was asked: the answer is for a session it + // no longer shows, which another session may now hold. + return + } + if (choice === undefined) { + this.deps.log.warn( + `ACP session ${this.sessionId}: approval ${event.approvalId} offers no denial; stopping the turn`, + ) + await this.cancel() + return + } + try { + await this.session.decideApproval({ + approvalId: event.approvalId, + choiceId: choice.choiceId, + requirementId: event.requirementId, + }) + } catch (error: unknown) { + const level = isPromptSettledError(error) ? 'info' : 'warn' + this.deps.log[level]( + `ACP session ${this.sessionId}: approval ${event.approvalId}: ${failureForLog(error)}`, + ) + } + } + + private async ask(event: QuestionRequest): Promise { + const pending = this.pending + try { + if (this.clientCapabilities.elicitation?.form == null) { + this.send({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: questionsText(event.questions) }, + }) + } else { + const request: CreateElicitationRequest = { + sessionId: this.sessionId, + mode: 'form', + message: UI_TEXT.acpQuestionFormMessage, + requestedSchema: questionForm(event.questions), + } + const response = await this.client.request('elicitation/create', request) + if (!this.isCurrentPrompt(pending)) { + return + } + const answers = formAnswers(event.questions, response) + if (answers !== undefined) { + await this.session.answerQuestions(event.userInputId, answers) + return + } + this.deps.log.info( + `ACP session ${this.sessionId}: question ${event.userInputId} declined: the form came back without an answer that fits each question`, + ) + } + if (this.isCurrentPrompt(pending)) { + await this.session.cancelQuestions(event.userInputId) + } + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: question ${event.userInputId}: ${failureForLog(error)}`, + ) + // A form that failed is declined, so the turn goes on without the answer. + if (this.isCurrentPrompt(pending)) { + await this.declineQuestions(event.userInputId) + } + } + } + + /** + * A session let go changes nothing more on the backend, which a newer + * load may now hold, and replays nothing: the request fails instead. + */ + private ensureHeld(): void { + if (this.isDisposed) { + throw RequestError.resourceNotFound(this.sessionId) + } + } + + /** A late answer belongs only to the prompt that asked, while that prompt still runs. */ + private isCurrentPrompt(pending: PendingPrompt | undefined): boolean { + return !this.isDisposed && this.pending === pending && pending?.isCancelled !== true + } + + private isCurrentPaidPrompt( + pending: PendingPrompt | undefined, + preparing: PreparingPrompt | undefined, + ): boolean { + return ( + this.isCurrentPrompt(pending) && + this.preparing === preparing && + preparing?.isCancelled !== true + ) + } + + private async cancelTurn(): Promise { + try { + await this.session.cancel() + } catch (error: unknown) { + this.deps.log.warn(`ACP session ${this.sessionId}: cancel failed: ${failureForLog(error)}`) + } + } + + private async declineQuestions(userInputId: string): Promise { + if (this.isDisposed) { + return + } + try { + await this.session.cancelQuestions(userInputId) + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: question ${userInputId} not declined: ${failureForLog(error)}`, + ) + } + } + + /** + * The question before a paid use (M58, PLAN.md D48): a row naming what is + * about to be billed and its price, and a permission prompt on it with the + * popup's answers. Anything but Allow once or Allow always is Deny. + */ + public async askPaidUse(request: PaidUseRequest, canRemember: boolean): Promise { + const pending = this.pending + const preparing = this.preparing + // Unique for the client's lifetime: a session loaded again starts afresh. + const toolCallId = `${ACP_PAID_TOOL_CALL_PREFIX}${randomUUID()}` + const { title, detail } = paidUseQuestion(request) + const content = [{ type: 'content' as const, content: { type: 'text' as const, text: detail } }] + this.send({ + sessionUpdate: 'tool_call', + toolCallId, + title, + kind: 'other', + status: 'pending', + content, + }) + let answer: PaidUseAnswer = 'deny' + try { + await this.outbox + if (!this.isCurrentPaidPrompt(pending, preparing)) { + return 'deny' + } + const params: RequestPermissionRequest = { + sessionId: this.sessionId, + toolCall: { toolCallId, title, status: 'pending', content }, + options: paidUseOptions(canRemember), + } + const response = await this.client.request('session/request_permission', params) + // A session let go while the editor was asked is billed for nothing. + answer = this.isCurrentPaidPrompt(pending, preparing) + ? paidUseAnswer(permissionResponse(response), canRemember) + : 'deny' + } catch (error: unknown) { + this.deps.log.warn( + `ACP session ${this.sessionId}: the paid-use question failed, denying: ${failureForLog(error)}`, + ) + } + this.send({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: answer === 'deny' ? 'failed' : 'completed', + }) + return answer + } + + public modes(): SessionModeState { + return { + currentModeId: this.mode, + availableModes: availablePermissionModes(this.deps.options.canBypass).map((mode) => ({ + id: mode, + name: UI_TEXT.permissionModes[mode], + description: permissionModeDetail(mode, this.deps.backend.kind), + })), + } + } + + public configOptions(): SessionConfigOption[] { + return [ + { + id: ACP_CONFIG_IDS.model, + name: UI_TEXT.groupModel, + category: 'model', + type: 'select', + currentValue: this.modelId, + options: this.models.map((model) => ({ value: model.modelId, name: model.displayLabel })), + }, + { + id: ACP_CONFIG_IDS.effort, + name: UI_TEXT.effortItem, + category: 'thought_level', + type: 'select', + currentValue: this.effort, + options: effortLevelsFor(this.modelId).map((level) => ({ + value: level, + name: effortLabel(level), + })), + }, + ] + } + + /** + * A loaded or resumed session made to run as the agent advertises it. + * Everything shown comes from the backend's answer or an explicit set, + * never from what the handle holds: Muse Code's resumed handle holds the + * model the agent asked for, while the CLI keeps the one the session last + * ran on. The permission mode shown is set; the model is the one the + * backend reports active where the agent lists it, else the default, set + * (a contributor model the agent hides is left); the effort shown is set. + */ + public async matchAdvertised(): Promise { + this.ensureHeld() + await this.session.setApprovalMode(approvalModeFor(this.mode, true)) + const reported = await this.host.listModels(this.sessionId) + const active = reported.find((model) => model.isActive) + if (active !== undefined && this.models.some((model) => model.modelId === active.modelId)) { + this.modelId = active.modelId + } else { + const listed = startingModel(this.models) + this.ensureHeld() + await this.session.setModel(listed) + this.modelId = listed + } + await this.applyEffort(this.effort) + } + + /** The session's standing effort, as the panel sets it on a new session. */ + public async applyEffort(effort: EffortLevel): Promise { + const served = servedEffort(this.modelId, effort) + this.ensureHeld() + await this.session.setReasoningEffort(effortForThinking(served, true)) + this.effort = served + } + + public async setMode(modeId: string): Promise { + const mode = availablePermissionModes(this.deps.options.canBypass).find( + (candidate) => candidate === modeId, + ) + if (mode === undefined) { + throw RequestError.invalidParams(undefined, modeId) + } + this.ensureHeld() + await this.session.setApprovalMode(approvalModeFor(mode, true)) + this.mode = mode + } + + public async setConfigOption(configId: string, value: unknown): Promise { + if (typeof value !== 'string') { + throw RequestError.invalidParams(undefined, configId) + } + if (configId === ACP_CONFIG_IDS.model) { + if (this.models.every((model) => model.modelId !== value)) { + throw RequestError.invalidParams(undefined, value) + } + this.ensureHeld() + await this.session.setModel(value) + this.modelId = value + await this.applyEffort(this.effort) + return + } + if (configId !== ACP_CONFIG_IDS.effort) { + throw RequestError.invalidParams(undefined, configId) + } + if (!isEffortLevel(value) || !effortLevelsFor(this.modelId).includes(value)) { + throw RequestError.invalidParams(undefined, value) + } + await this.applyEffort(value) + } + + /** A loaded session's history, as the updates a live one would have sent. */ + public async replay(items: Parameters[0][]): Promise { + this.ensureHeld() + const history = new UpdateTranslator(this.cwd, true) + for (const item of items) { + for (const update of history.itemUpdates(item, true)) { + this.send(update) + } + } + await this.outbox + } + + public sendPlan(todos: Parameters[0]): void { + if (todos.length > 0) { + this.send({ sessionUpdate: 'plan', entries: planEntries(todos) }) + } + } + + public async prompt(blocks: readonly ContentBlock[]): Promise { + this.ensureHeld() + if (this.pending !== undefined || this.preparing !== undefined) { + throw RequestError.invalidRequest(undefined, UI_TEXT.acpPromptBusy) + } + const parsed = promptParts(blocks, this.cwd) + if (!parsed.ok) { + throw RequestError.invalidParams(undefined, parsed.reason) + } + const preparing: PreparingPrompt = { isCancelled: false } + this.preparing = preparing + try { + await this.announceCommands() + } finally { + this.preparing = undefined + } + if ('error' in preparing) { + throw preparing.error + } + if (preparing.isCancelled) { + await this.outbox + return 'cancelled' + } + // Outcomes are values: a host exit before turn/start answers must not + // reject a promise that the prompt has not yet reached (Node would exit). + const finished = new Promise((resolve) => { + this.pending = { + resolve: (reason) => { + resolve({ reason }) + }, + reject: (error: unknown) => { + resolve({ error }) + }, + turnId: undefined, + isCancelled: false, + } + }) + try { + const starting = this.session.sendTurn(this.withSkill(parsed.parts), parsed.displayText) + this.starting = starting + const submission = await starting + this.noteTurnId(submission.turnId) + } catch (error: unknown) { + this.pending = undefined + if (this.isDisposed) { + // Let go while starting: a close cancels; a host exit fails. + const outcome = await finished + if ('error' in outcome) { + throw outcome.error + } + return outcome.reason + } + throw error + } finally { + this.starting = undefined + } + const outcome = await finished + if ('error' in outcome) { + throw outcome.error + } + await this.outbox + return outcome.reason + } + + public async cancel(): Promise { + if (this.preparing !== undefined) { + this.preparing.isCancelled = true + this.deps.log.info(`ACP session ${this.sessionId}: cancelled before its turn started`) + return + } + if (this.pending === undefined) { + return + } + this.pending.isCancelled = true + // Stopped once its start is answered, as in release(): a stop sent + // while the turn is still starting finds no turn, and the turn would + // then run on, editing and billing, while the editor is told it ended. + try { + await this.starting + } catch { + // The prompt that started it reports the failed start. + } + await this.cancelTurn() + } + + /** The backend went away: the running prompt ends with its reason. */ + public hostExited(description: string): void { + const error = RequestError.internalError(undefined, description) + if (this.preparing !== undefined) { + this.preparing.error = error + } + this.pending?.reject(error) + this.pending = undefined + } + + public get isReleased(): boolean { + return this.isDisposed + } + + /** + * Let go (closed, loaded again, or never set up). At once it stops + * following the backend, the editor's late answers decide nothing, and a + * prompt it was running ends cancelled. That turn is then stopped on the + * backend too, once it has started, as the editor was told; a turn left + * running would go on editing and billing with no one watching, and a + * session loaded again shares the backend session, so nothing else would + * stop it. Then the backend session is let go. + */ + public async release(): Promise { + if (this.isDisposed) { + return + } + this.isDisposed = true + if (this.preparing !== undefined) { + this.preparing.isCancelled = true + } + const wasRunning = this.pending !== undefined + this.pending?.resolve('cancelled') + this.pending = undefined + this.unsubscribe() + if (wasRunning) { + // Stopped once its start is answered, even a start that failed: one + // past its deadline (Muse Code's `turn/start`) may still start. + try { + await this.starting + } catch { + // The prompt that started it has already ended cancelled. + } + await this.cancelTurn() + } + this.session.dispose() + } +} + +/** The agent's state across the connection: the client's capabilities and the live sessions. */ +class AgentState { + private readonly sessions = new Map() + /** Sessions being set up (`adopt`), by id: a newer load or a close lets them go too. */ + private readonly adopting = new Map() + private readonly claims = new Map() + /** Remains visible even when the session is no longer available for requests. */ + private readonly releasing = new Map>() + private readonly watchedHosts = new WeakSet() + private readonly exitedHosts = new WeakSet() + private clientCapabilities: ClientCapabilities = {} + + public constructor(private readonly deps: AcpAgentDeps) {} + + /** + * The sign-in: run by the client in a terminal where it can (the spec + * allows a terminal method only then), otherwise by the user, whose + * `authenticate` this checks. + */ + private authMethod(): AuthMethod { + const { id, name, description, args, command } = this.deps.signIn + return this.clientCapabilities.auth?.terminal === true + ? { type: 'terminal', id, name, description, args: [...args] } + : { id, name, description: fill(UI_TEXT.acpSignInByHand, { command }) } + } + + private async requireReady(isRecheck = false): Promise { + const readiness = await this.deps.backend.readiness(isRecheck) + if (readiness.state === 'signedOut') { + throw RequestError.authRequired(undefined, readiness.message) + } + if (readiness.state === 'unavailable') { + throw RequestError.internalError(undefined, readiness.message) + } + } + + private async openHost( + cwd: string, + ): Promise<{ readonly host: AgentHost; readonly models: readonly ModelSummary[] }> { + if (!path.isAbsolute(cwd)) { + throw RequestError.invalidParams(undefined, cwd) + } + await this.requireReady() + const host = await this.deps.backend.hostFor(cwd) + this.watch(host) + const listed = await host.listModels() + const models = this.deps.options.allowsContributorModels + ? listed + : listed.filter((model) => !isContributorModel(model.modelId)) + return { host, models } + } + + /** + * A backend session the agent now owns (new, loaded or resumed), held only + * once `prepare` has set what the editor is shown: until then no request + * finds it, and if anything fails the backend session is let go and the + * request fails, so no session outlives a request that returned no id. + */ + private async adopt( + host: AgentHost, + session: AgentSession, + cwd: string, + client: AgentContext, + models: readonly ModelSummary[], + claim: SessionClaim, + prepare: (acp: AcpSession) => Promise, + ): Promise { + // A session loaded again replaces the one held, and one still being set + // up by an earlier load, before anything runs on it: both hosts hand + // back a session they hold, retained, so they would share it. Those stop + // following it and answering at once (`release`); if this load then + // fails, nothing is held for that id and the editor loads it again. + const { sessionId } = session + let acp: AcpSession | undefined + try { + this.ensureClaim(claim, host) + // One shared barrier survives removal from the request maps. The + // latest request alone can attach after the old turn is stopped. + await this.releaseAll(sessionId) + this.ensureClaim(claim, host) + acp = new AcpSession( + session, + host, + cwd, + client, + this.clientCapabilities, + models, + this.deps, + this.deps.options.initialMode, + session.modelId, + ) + this.adopting.set(sessionId, acp) + await prepare(acp) + this.ensureClaim(claim, host) + if (acp.isReleased) { + // A newer load of this session, or a close, let it go meanwhile. + throw RequestError.resourceNotFound(sessionId) + } + } catch (error: unknown) { + if (acp === undefined) { + session.dispose() + } else { + await acp.release() + } + if (this.claims.get(sessionId) === claim) { + this.claims.delete(sessionId) + } + throw error + } finally { + if (this.adopting.get(sessionId) === acp) { + this.adopting.delete(sessionId) + } + } + this.sessions.set(sessionId, acp) + return acp + } + + private claimSession(sessionId: string): SessionClaim { + const claim = { sessionId } + this.claims.set(sessionId, claim) + return claim + } + + private ensureClaim(claim: SessionClaim, host: AgentHost): void { + if (this.claims.get(claim.sessionId) !== claim || this.exitedHosts.has(host)) { + throw RequestError.resourceNotFound(claim.sessionId) + } + } + + /** + * Lets go of the session held for `sessionId` and one being set up, so no + * request finds either; resolves once each has stopped its turn and let + * its backend session go. False when there was neither. + */ + private async releaseAll(sessionId: string): Promise { + const found = [this.sessions.get(sessionId), this.adopting.get(sessionId)].filter( + (acp) => acp !== undefined, + ) + this.sessions.delete(sessionId) + this.adopting.delete(sessionId) + const previous = this.releasing.get(sessionId) + if (previous === undefined && found.length === 0) { + return false + } + const released = Promise.all([previous, ...found.map((acp) => acp.release())]) + this.releasing.set(sessionId, released) + try { + await released + } finally { + if (this.releasing.get(sessionId) === released) { + this.releasing.delete(sessionId) + } + } + return true + } + + private watch(host: AgentHost): void { + if (this.watchedHosts.has(host)) { + return + } + this.watchedHosts.add(host) + host.onExit((exit) => { + this.exitedHosts.add(host) + this.deps.log.warn(`The ${host.info.kind} backend stopped: ${exit.description}`) + for (const [sessionId, acp] of [...this.sessions, ...this.adopting]) { + if (acp.host !== host) { + continue + } + acp.hostExited(exit.description) + this.claims.delete(sessionId) + void this.releaseAll(sessionId) + } + }) + } + + /** + * The editor's MCP servers for a session (M63c): passed to Muse Code when + * it granted `sessionMcp`; the Model API backend runs none. Only their + * names are logged, as headers and environments can hold secrets. + */ + private forwardedMcp( + host: AgentHost, + requested: readonly McpServer[] | undefined, + ): Readonly> | undefined { + if (requested === undefined || requested.length === 0) { + return undefined + } + const names = requested.map((server) => server.name).join(', ') + if (host.info.kind !== 'museCode') { + this.deps.log.warn( + `MCP servers from the editor not passed on (${names}): the ${host.info.kind} backend runs none`, + ) + return undefined + } + if (!host.info.grantedCapabilities.includes(SESSION_MCP_CAPABILITY)) { + this.deps.log.warn( + `MCP servers from the editor not passed on (${names}): Muse Code did not grant ${SESSION_MCP_CAPABILITY}`, + ) + return undefined + } + const { servers, skipped } = mcpServersFrom(requested) + if (skipped.length > 0) { + this.deps.log.warn( + `MCP servers from the editor left out (SSE, or a name used twice): ${skipped.join(', ')}`, + ) + } + this.deps.log.info(`MCP servers from the editor: ${Object.keys(servers).join(', ')}`) + return servers + } + + public initialize(clientCapabilities: ClientCapabilities | undefined): InitializeResponse { + this.clientCapabilities = clientCapabilities ?? {} + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + // Stdio servers every agent takes; HTTP ones Muse Code runs too (M63c). + mcpCapabilities: { http: this.deps.backend.kind === 'museCode', sse: false }, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, + authMethods: [this.authMethod()], + agentInfo: { name: ACP_AGENT_NAME, title: ACP_AGENT_TITLE, version: this.deps.version }, + } + } + + /** Confirms the sign-in took; the client asks again if not. */ + public async authenticate(): Promise> { + await this.requireReady(true) + return {} + } + + public async newSession( + cwd: string, + requestedMcp: readonly McpServer[] | undefined, + client: AgentContext, + ) { + const { host, models } = await this.openHost(cwd) + const modelId = startingModel(models) + const mcpServers = this.forwardedMcp(host, requestedMcp) + const session = await host.startSession({ + workspaceRoot: cwd, + modelId, + approvalMode: approvalModeFor(this.deps.options.initialMode, true), + ...(mcpServers !== undefined && { mcpServers }), + }) + // The mode and model went with the start; the effort is set here. + const claim = this.claimSession(session.sessionId) + const acp = await this.adopt(host, session, cwd, client, models, claim, (started) => + started.applyEffort(DEFAULT_EFFORT), + ) + return { sessionId: session.sessionId, modes: acp.modes(), configOptions: acp.configOptions() } + } + + public async loadSession( + sessionId: string, + cwd: string, + requestedMcp: readonly McpServer[] | undefined, + client: AgentContext, + isReplayed: boolean, + ) { + // Claimed before backend I/O: a slow older resume cannot replace a + // newer request, and a close can invalidate one still loading. + const claim = this.claimSession(sessionId) + try { + const { host, models } = await this.openHost(cwd) + this.ensureClaim(claim, host) + const loaded = await host.resumeSession( + sessionId, + startingModel(models), + this.forwardedMcp(host, requestedMcp), + ) + // A session resumes on the approval mode, model and effort it last had, + // which may differ from what the editor is told (a more permissive mode, + // a hidden model): they are set before anything is replayed, as the + // panel sets its own on a resume. Nothing is shown that does not run: + // if the backend refuses, the load fails instead. + const acp = await this.adopt( + host, + loaded.session, + cwd, + client, + models, + claim, + async (resumed) => { + await resumed.matchAdvertised() + if (!isReplayed) { + return + } + await resumed.replay([...loaded.history.items]) + resumed.sendPlan(loaded.history.todos) + }, + ) + return { modes: acp.modes(), configOptions: acp.configOptions() } + } catch (error: unknown) { + if (this.claims.get(sessionId) === claim) { + this.claims.delete(sessionId) + } + throw error + } + } + + public async listSessions( + cwd: string | undefined, + cursor: string | undefined, + ): Promise { + const folder = cwd ?? this.deps.defaultCwd + const { host } = await this.openHost(folder) + const page = await host.listSessions({ + workspaceRoot: folder, + limit: ACP_SESSION_LIST_LIMIT, + ...(cursor !== undefined && { cursor }), + }) + return { + sessions: page.sessions.map((record) => ({ + sessionId: record.sessionId, + cwd: record.workspaceRoot ?? folder, + title: record.name ?? record.title ?? record.firstUserPrompt ?? null, + updatedAt: record.lastActivityAt ?? record.updatedAt, + })), + nextCursor: page.nextCursor ?? null, + } + } + + public session(sessionId: string): AcpSession { + const found = this.held(sessionId) + if (found === undefined) { + throw RequestError.resourceNotFound(sessionId) + } + return found + } + + /** The session held under this id, if any (none while it is being set up). */ + public held(sessionId: string): AcpSession | undefined { + return this.sessions.get(sessionId) + } + + /** The editor closes a session: the one held, or one still being set up. */ + public async closeSession(sessionId: string): Promise { + const wasClaimed = this.claims.delete(sessionId) + const wasReleased = await this.releaseAll(sessionId) + if (!wasClaimed && !wasReleased) { + throw RequestError.resourceNotFound(sessionId) + } + } + + /** A paid use asked in the session it is for; one the agent does not hold is denied (M58). */ + public async askPaidUse( + sessionId: string, + request: PaidUseRequest, + canRemember: boolean, + ): Promise { + const acp = this.sessions.get(sessionId) + if (acp === undefined) { + this.deps.log.warn( + `Paid use of ${request.feature}: no editor session ${sessionId} to ask in, so it is denied`, + ) + return 'deny' + } + return await acp.askPaidUse(request, canRemember) + } +} + +/** The agent: register it on a stream with `connect`. */ +export function createAcpAgent(deps: AcpAgentDeps): AgentApp { + const state = new AgentState(deps) + deps.paid.attach((sessionId, request, canRemember) => + state.askPaidUse(sessionId, request, canRemember), + ) + return acpAgent({ name: ACP_AGENT_NAME }) + .onRequest('initialize', (context) => state.initialize(context.params.clientCapabilities)) + .onRequest('authenticate', () => state.authenticate()) + .onRequest('session/new', (context) => + state.newSession(context.params.cwd, context.params.mcpServers, context.client), + ) + .onRequest('session/load', (context) => { + const { sessionId, cwd, mcpServers } = context.params + return state.loadSession(sessionId, cwd, mcpServers, context.client, true) + }) + .onRequest('session/resume', (context) => { + const { sessionId, cwd, mcpServers } = context.params + return state.loadSession(sessionId, cwd, mcpServers ?? undefined, context.client, false) + }) + .onRequest('session/list', (context) => + state.listSessions(context.params.cwd ?? undefined, context.params.cursor ?? undefined), + ) + .onRequest('session/close', async (context) => { + await state.closeSession(context.params.sessionId) + return {} + }) + .onRequest('session/set_mode', async (context) => { + await state.session(context.params.sessionId).setMode(context.params.modeId) + return {} + }) + .onRequest('session/set_config_option', async (context) => { + const session = state.session(context.params.sessionId) + await session.setConfigOption(context.params.configId, context.params.value) + return { configOptions: session.configOptions() } + }) + .onRequest('session/prompt', async (context) => ({ + stopReason: await state.session(context.params.sessionId).prompt(context.params.prompt), + })) + .onNotification('session/cancel', async (context) => { + // A notification has no answer: a cancel for a session already + // closed, still being set up, or never held stops nothing. + await state.held(context.params.sessionId)?.cancel() + }) +} diff --git a/src/acp/paid.ts b/src/acp/paid.ts new file mode 100644 index 00000000..827142e5 --- /dev/null +++ b/src/acp/paid.ts @@ -0,0 +1,194 @@ +// The paid Model API features in the agent (M63c, M58; PLAN.md D30, D48, +// D62): "opt in and loud", as in the panel. A feature is off unless the +// editor started the agent with its flag. Every use then asks in the editor, +// as the panel's popup does (D48): a permission prompt that names what is +// about to be billed and its price, with Allow once, Allow always in this +// workspace and Deny. "Always" is offered and kept only with +// `--trust-workspace`, as the panel offers it only in a trusted workspace; +// it is kept per folder in the agent's data folder (runtime/paidGrants.ts) +// and lapses when the agent starts without the feature's flag, so turning +// the flag on again asks again. A use with no session to ask in (no client, +// or a conversation the agent does not hold) is denied, and so is every +// feature the agent has no flag for: subagents, Muse Voice and scheduled +// runs. + +import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk' +import { PaidUseConsent, type PaidUseAnswer } from '../core/paid/paidConsent' +import { failureForLog } from '../core/backends/musecode/logText' +import type { CoreLogger } from '../core/logging' +import { + ACP_PAID_FEATURES, + ACP_PAID_OPTIONS, + type AcpPaidFeature, + type PaidFeature, + UI_TEXT, +} from '../shared/constants' +import type { PaidUseRequest } from '../shared/paid' + +/** Where "Allow always in this workspace" is kept, per folder. */ +export interface PaidGrantStore { + readonly read: (workspaceRoot: string) => ReadonlySet + /** Adds these features to the folder's grants, merged with the store as it then is. */ + readonly add: (workspaceRoot: string, features: readonly PaidFeature[]) => Promise + /** Takes these features out of every folder's grants. */ + readonly forget: (features: readonly PaidFeature[]) => Promise +} + +/** The question in one of the client's sessions; the agent attaches it (agent.ts). */ +export type PaidUseAsker = ( + sessionId: string, + request: PaidUseRequest, + canRemember: boolean, +) => Promise + +export interface AcpPaidUseDeps { + /** The features whose flags the editor gave. */ + readonly flagged: readonly AcpPaidFeature[] + /** `--trust-workspace`: where "always" is offered and honoured. */ + readonly canRemember: () => boolean + readonly grants: PaidGrantStore + readonly log: CoreLogger +} + +/** The agent's own store's failure: its code and our own data folder's path, never a CLI's text. */ +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** The prompt's options: the popup's answers (D48), "always" only where it is kept. */ +export function paidUseOptions(canRemember: boolean): PermissionOption[] { + const always: PermissionOption = { + optionId: ACP_PAID_OPTIONS.allowAlways, + name: UI_TEXT.paidAllowAlways, + kind: 'allow_always', + } + return [ + { optionId: ACP_PAID_OPTIONS.allowOnce, name: UI_TEXT.allowOnce, kind: 'allow_once' }, + ...(canRemember ? [always] : []), + { optionId: ACP_PAID_OPTIONS.deny, name: UI_TEXT.paidDeny, kind: 'reject_once' }, + ] +} + +/** + * The client's answer. A cancel, or an option the prompt did not offer, is + * Deny: nothing is billed by a translation default (D62). + */ +export function paidUseAnswer( + response: RequestPermissionResponse, + canRemember: boolean, +): PaidUseAnswer { + const { outcome } = response + if (outcome.outcome !== 'selected') { + return 'deny' + } + if (outcome.optionId === ACP_PAID_OPTIONS.allowOnce) { + return 'once' + } + return canRemember && outcome.optionId === ACP_PAID_OPTIONS.allowAlways ? 'always' : 'deny' +} + +export class AcpPaidUse { + private asker: PaidUseAsker | undefined + private readonly used = new Map() + + public constructor(private readonly deps: AcpPaidUseDeps) {} + + private async ask( + sessionId: string, + request: PaidUseRequest, + canRemember: boolean, + ): Promise { + const { asker } = this + if (asker === undefined) { + this.deps.log.warn(`Paid use of ${request.feature}: no editor to ask, so it is denied`) + return 'deny' + } + try { + return await asker(sessionId, request, canRemember) + } catch (error: unknown) { + this.deps.log.warn( + `Paid use of ${request.feature}: the editor could not be asked, so it is denied: ${failureForLog(error)}`, + ) + return 'deny' + } + } + + /** + * "Allow always" for the folder: only what this answer adds, merged into + * the file as it is when written, so a set read before another agent's + * change is never written back over it. + */ + private async keep(workspaceRoot: string, grants: ReadonlySet): Promise { + const held = this.deps.grants.read(workspaceRoot) + await this.deps.grants.add( + workspaceRoot, + [...grants].filter((feature) => !held.has(feature)), + ) + } + + /** Whether the backend may use the feature at all: its flag given. */ + public isOn(feature: PaidFeature): boolean { + const flagged: readonly PaidFeature[] = this.deps.flagged + return flagged.includes(feature) + } + + /** The agent's way to ask; until it is attached, every use is denied. */ + public attach(asker: PaidUseAsker): void { + this.asker = asker + } + + /** The popup before a use in the folder's conversation `sessionId` (D48). */ + public async allows( + workspaceRoot: string, + sessionId: string, + request: PaidUseRequest, + requiresAsking: boolean, + ): Promise { + const consent = new PaidUseConsent({ + isOn: (feature) => this.isOn(feature), + canRemember: this.deps.canRemember, + readGrants: () => this.deps.grants.read(workspaceRoot), + writeGrants: (grants) => this.keep(workspaceRoot, grants), + ask: (asked, canRemember) => this.ask(sessionId, asked, canRemember), + log: this.deps.log, + }) + return await consent.allows(request, requiresAsking) + } + + /** Whether the feature is on and allowed always in the folder, so it asks nothing. */ + public isRemembered(workspaceRoot: string, feature: PaidFeature): boolean { + return ( + this.isOn(feature) && + this.deps.canRemember() && + this.deps.grants.read(workspaceRoot).has(feature) + ) + } + + /** + * At start: a feature without its flag loses "always" in every folder, so + * turning the flag on again asks again. A file that cannot be written is + * logged; the grant is still never honoured while the flag is off. + */ + public async forgetUnflagged(): Promise { + const unflagged = ACP_PAID_FEATURES.filter((feature) => !this.isOn(feature)) + if (unflagged.length === 0) { + return + } + try { + await this.deps.grants.forget(unflagged) + } catch (error: unknown) { + this.deps.log.warn( + `Paid uses allowed always could not be forgotten for ${unflagged.join(', ')}: ${describe(error)}`, + ) + } + } + + /** A billed use, tallied for the log: the agent has no usage dialog. */ + public noteUse(feature: PaidFeature, units: number): void { + const total = (this.used.get(feature) ?? 0) + units + this.used.set(feature, total) + this.deps.log.info( + `Paid use of ${feature}: ${String(units)}, ${String(total)} since the agent started`, + ) + } +} diff --git a/src/acp/questions.ts b/src/acp/questions.ts new file mode 100644 index 00000000..f1f6a980 --- /dev/null +++ b/src/acp/questions.ts @@ -0,0 +1,139 @@ +// The agent's questions (`request_user_input`) in an ACP client (PLAN.md +// D62): a form where the client can show one (`elicitation/create`), and +// otherwise the questions as text, declined so the model carries on and the +// user answers in the next prompt. The client's answer is parsed before use +// (AGENTS.md rule 7): the ACP SDK checks what the agent receives, not what a +// request of its own gets back. Pure. + +import type { ElicitationPropertySchema, ElicitationSchema } from '@agentclientprotocol/sdk' +import * as z from 'zod/mini' +import type { Question, QuestionAnswer } from '../shared/agentEvents' +import { UI_TEXT } from '../shared/constants' + +const MULTIPLE_SELECTION = 'multiple' +const LINE = '\n' +const BULLET = '- ' + +function enumOptions(question: Question) { + return question.options.map((option) => ({ + const: option.label, + title: option.label, + ...(option.description !== undefined && { description: option.description }), + })) +} + +function questionProperty(question: Question): ElicitationPropertySchema { + const titled = { title: question.header, description: question.question } + if (question.selection.mode === MULTIPLE_SELECTION) { + return { + type: 'array', + ...titled, + items: { anyOf: enumOptions(question) }, + ...(question.selection.minSelections !== undefined && { + minItems: question.selection.minSelections, + }), + ...(question.selection.maxSelections !== undefined && { + maxItems: question.selection.maxSelections, + }), + } + } + return question.options.length === 0 + ? { type: 'string', ...titled } + : { type: 'string', ...titled, oneOf: enumOptions(question) } +} + +/** One form field per question, each required, as the card asks for an answer to each. */ +export function questionForm(questions: readonly Question[]): ElicitationSchema { + return { + type: 'object', + properties: Object.fromEntries( + questions.map((question) => [question.id, questionProperty(question)]), + ), + required: questions.map((question) => question.id), + } +} + +// The client's answer: accepted with the form's values (ACP allows none, or +// null), or declined or cancelled. Anything else is no answer. +const elicitationResponseSchema = z.union([ + z.object({ + action: z.literal('accept'), + content: z.optional(z.nullable(z.record(z.string(), z.unknown()))), + }), + z.object({ action: z.enum(['decline', 'cancel']) }), +]) +const textSchema = z.string() +const labelsSchema = z.array(z.string()) + +/** + * One question's answer as the form asked for it, or undefined: a single + * choice one of its options, free text (non-blank) only where it has none, + * and a multiple choice distinct options, as many as it allows. + */ +function answerOf(question: Question, value: unknown): QuestionAnswer | undefined { + const labels = new Set(question.options.map((option) => option.label)) + if (question.selection.mode === MULTIPLE_SELECTION) { + const parsed = labelsSchema.safeParse(value) + if (!parsed.success) { + return undefined + } + const picked = parsed.data + const min = question.selection.minSelections ?? 1 + const max = question.selection.maxSelections ?? labels.size + const isValid = + new Set(picked).size === picked.length && + picked.every((label) => labels.has(label)) && + picked.length >= min && + picked.length <= max + return isValid ? { questionId: question.id, selectedLabels: picked } : undefined + } + const parsed = textSchema.safeParse(value) + if (!parsed.success) { + return undefined + } + if (labels.size === 0) { + return parsed.data.trim() === '' + ? undefined + : { questionId: question.id, freeText: parsed.data } + } + return labels.has(parsed.data) + ? { questionId: question.id, selectedLabel: parsed.data } + : undefined +} + +/** + * The form's answers as the backend takes them, or undefined when the form + * was not accepted or any answer is not one the form allowed: the question + * is then declined, and nothing is answered by a guess (D62). + */ +export function formAnswers( + questions: readonly Question[], + raw: unknown, +): readonly QuestionAnswer[] | undefined { + const response = elicitationResponseSchema.safeParse(raw) + if (!response.success || response.data.action !== 'accept') { + return undefined + } + const content = response.data.content ?? {} + const answers: QuestionAnswer[] = [] + for (const question of questions) { + const answer = answerOf( + question, + Object.hasOwn(content, question.id) ? content[question.id] : undefined, + ) + if (answer === undefined) { + return undefined + } + answers.push(answer) + } + return answers +} + +/** The questions as a message, for a client without forms. */ +export function questionsText(questions: readonly Question[]): string { + const lines = questions.flatMap((question) => [ + question.question, + ...question.options.map((option) => `${BULLET}${option.label}`), + ]) + return [UI_TEXT.acpQuestionAsked, ...lines].join(LINE) +} diff --git a/src/acp/translate.ts b/src/acp/translate.ts new file mode 100644 index 00000000..170ec0bb --- /dev/null +++ b/src/acp/translate.ts @@ -0,0 +1,626 @@ +// What an ACP client sees of a Muse Spark conversation (PLAN.md D62): the +// panel's AgentEvents become `session/update` notifications, a prompt's +// content blocks become turn parts, and the backend's approval choices +// become permission options. Pure; `agent.ts` sends what these return. + +import { Buffer } from 'node:buffer' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import * as z from 'zod/mini' +import type { + ContentBlock, + McpServer, + PermissionOption, + PermissionOptionKind, + PlanEntry, + PlanEntryStatus, + RequestPermissionResponse, + SessionUpdate, + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallUpdate, + ToolKind, +} from '@agentclientprotocol/sdk' +import type { SessionMcpServer, TurnPart } from '../core/agent/agentBackend' +import { readImageInfo } from '../core/imageDimensions' +import type { + AgentEvent, + ApprovalChoice, + ApprovalSubject, + ItemSnapshot, + TodoItem, +} from '../shared/agentEvents' +import { + ACP_TOOL_OUTPUT_MAX_CHARS, + FILE_EDIT_TOOLS, + FILE_READ_TOOLS, + IDE_CONTEXT_TAGS, + IMAGE_MAKING_TOOLS, + MAX_IMAGE_BYTES, + MODEL_API_WEB_SEARCH_TOOL, + SELECTION_TEXT_MAX_CHARS, + SHELL_TOOLS, + type PaidFeature, + UI_TEXT, +} from '../shared/constants' +import { fill } from '../shared/l10n/text' +import { formatMention } from '../shared/mentions' +import { paidFeaturePrice } from '../shared/paid' + +const TEXT_FIELD = 'text' +const OUTPUT_FIELD = 'output' +// A reasoning item streams its summary parts as `summary.0`, `summary.1`, … +const SUMMARY_FIELD = /^summary\.(\d+)$/ +const PART_SEPARATOR = '\n\n' +const FILE_SCHEME = 'file:' +const APPROVED_DECISION_PREFIX = 'approved' +const ONCE_SCOPE = 'once' +const MCP_TOOL = /^mcp__(.+?)__(.+)$/ +// The argument fields that say what a call is about, in the order a title prefers them. +const TITLE_FIELDS = [ + 'description', + 'command', + 'path', + 'pattern', + 'query', + 'url', + 'objective', + 'prompt', +] as const +const SEARCH_TOOLS: ReadonlySet = new Set([ + 'search', + 'list_files', + 'tool_search', + MODEL_API_WEB_SEARCH_TOOL, +]) +const FETCH_TOOLS: ReadonlySet = new Set(['web_fetch']) +const EXECUTE_TOOLS: ReadonlySet = new Set(['code_exec']) +// A `!` command the user ran (`userShell` items) is shown as the shell tool. +const USER_SHELL_TOOL = 'shell' +const PLANNING_TOOLS: ReadonlySet = new Set([ + 'todo_write', + 'write_todos', + 'update_plan', + 'TodoWrite', +]) +const READING_TOOLS: ReadonlySet = new Set(['read_memory', 'read_skill']) +// Item statuses that ACP calls failed: MSP's `ItemStatus`, and the Model API +// backend's `rejected` for a call the user denied. +const FAILED_STATUSES: ReadonlySet = new Set([ + 'failed', + 'declined', + 'rejected', + 'cancelled', + 'interrupted', +]) +const TODO_IN_PROGRESS: ReadonlySet = new Set(['in_progress', 'inProgress']) +const TODO_DONE: ReadonlySet = new Set(['completed', 'done']) +const PLAN_PRIORITY = 'medium' + +type Arguments = Readonly> + +/** A call's arguments as an object; undefined when they are not JSON (they show as text then). */ +function parseArguments(raw: string | undefined): Arguments | undefined { + if (raw === undefined || raw === '') { + return undefined + } + try { + const parsed: unknown = JSON.parse(raw) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Arguments) + : undefined + } catch { + return undefined + } +} + +function stringField(args: Arguments | undefined, key: string): string | undefined { + const value = args?.[key] + return typeof value === 'string' && value !== '' ? value : undefined +} + +/** The table's name for a wire tool, an MCP tool as "tool (server)", or the name as it came. */ +export function toolName(tool: string): string { + const labels: Readonly> = UI_TEXT.toolLabels + if (Object.hasOwn(labels, tool)) { + return labels[tool] ?? tool + } + const mcp = MCP_TOOL.exec(tool) + return mcp === null + ? tool + : fill(UI_TEXT.mcpToolLabel, { server: mcp[1] ?? '', tool: mcp[2] ?? '' }) +} + +/** A billed row's title names what the key pays for it (M63c, "opt in and loud"). */ +function withPrice(title: string, paid: PaidFeature | undefined): string { + return paid === undefined + ? title + : `${title} (${fill(UI_TEXT.paidRowTitle, { price: paidFeaturePrice(paid) })})` +} + +/** "Edit: src/app.ts", "Bash: Run the tests": the name, and what the call is about. */ +function toolTitle(tool: string, args: Arguments | undefined): string { + const name = toolName(tool) + const detail = TITLE_FIELDS.map((key) => stringField(args, key)).find( + (value) => value !== undefined, + ) + return detail === undefined ? name : `${name}: ${detail}` +} + +/** The icon family a client shows for a tool. */ +export function toolKind(tool: string): ToolKind { + if (SHELL_TOOLS.has(tool) || EXECUTE_TOOLS.has(tool)) { + return 'execute' + } + if (FILE_EDIT_TOOLS.has(tool) || IMAGE_MAKING_TOOLS.has(tool)) { + return 'edit' + } + if (FILE_READ_TOOLS.has(tool) || READING_TOOLS.has(tool)) { + return 'read' + } + if (SEARCH_TOOLS.has(tool)) { + return 'search' + } + if (FETCH_TOOLS.has(tool)) { + return 'fetch' + } + return PLANNING_TOOLS.has(tool) ? 'think' : 'other' +} + +/** The file a call names, as the absolute path ACP asks for. */ +function toolLocations(args: Arguments | undefined, cwd: string): ToolCallLocation[] { + const file = stringField(args, 'path') + return file === undefined ? [] : [{ path: path.resolve(cwd, file) }] +} + +function clippedOutput(text: string): string { + return text.length > ACP_TOOL_OUTPUT_MAX_CHARS ? text.slice(0, ACP_TOOL_OUTPUT_MAX_CHARS) : text +} + +function textContent(text: string): ToolCallContent { + return { type: 'content', content: { type: 'text', text: clippedOutput(text) } } +} + +/** An edit's change as a diff where the arguments carry it (edit and write), else nothing. */ +function editDiff(tool: string, args: Arguments | undefined, cwd: string): ToolCallContent[] { + const file = stringField(args, 'path') + if (file === undefined || args === undefined || !FILE_EDIT_TOOLS.has(tool)) { + return [] + } + const absolute = path.resolve(cwd, file) + const oldText = args['old_str'] ?? args['old_string'] + const newText = args['new_str'] ?? args['new_string'] ?? args['content'] + if (typeof newText !== 'string') { + return [] + } + return [ + { + type: 'diff', + path: absolute, + oldText: typeof oldText === 'string' ? oldText : null, + newText, + }, + ] +} + +function toolStatus(status: string, isCompleted: boolean): ToolCallStatus { + if (FAILED_STATUSES.has(status)) { + return 'failed' + } + return isCompleted ? 'completed' : 'in_progress' +} + +/** What a finished call shows: its diff, then its output or why it failed. */ +function toolContent(item: ItemSnapshot, output: string, cwd: string): ToolCallContent[] { + const content = editDiff(item.tool ?? '', parseArguments(item.args), cwd) + const text = item.visibleOutput ?? output + if (text !== '') { + content.push(textContent(text)) + } + if (item.failureReason !== undefined && item.failureReason !== text) { + content.push(textContent(item.failureReason)) + } + return content +} + +function planStatus(status: string): PlanEntryStatus { + if (TODO_DONE.has(status)) { + return 'completed' + } + return TODO_IN_PROGRESS.has(status) ? 'in_progress' : 'pending' +} + +/** The todo list as an ACP plan: the whole list, every time. */ +export function planEntries(items: readonly TodoItem[]): PlanEntry[] { + return items.map((item) => ({ + content: TODO_IN_PROGRESS.has(item.status) ? (item.activeForm ?? item.text) : item.text, + priority: PLAN_PRIORITY, + status: planStatus(item.status), + })) +} + +/** + * Turns one session's AgentEvents into ACP session updates. It remembers, + * per item, how much text went out (an item can arrive whole after its + * deltas, or whole on its own in a replayed history) and a tool call's + * output so far, which ACP sends whole with the finished call. + */ +export class UpdateTranslator { + private readonly sentText = new Map() + private readonly summaryParts = new Map() + private readonly toolOutput = new Map() + private readonly announced = new Set() + /** Each item's kind, so a delta is routed by what it belongs to. */ + private readonly kinds = new Map() + + public constructor( + private readonly cwd: string, + /** Replaying a loaded history: the user's own messages go out too. */ + private readonly isReplay: boolean, + ) {} + + private deltaUpdates(itemId: string, field: string, delta: string): SessionUpdate[] { + if (field === OUTPUT_FIELD) { + this.toolOutput.set(itemId, `${this.toolOutput.get(itemId) ?? ''}${delta}`) + return [] + } + const kind = this.kinds.get(itemId) + const summary = SUMMARY_FIELD.exec(field) + if (kind === 'reasoning' && summary !== null) { + const index = Number(summary[1]) + const isNewPart = this.summaryParts.has(itemId) && this.summaryParts.get(itemId) !== index + this.summaryParts.set(itemId, index) + return [thoughtText(isNewPart ? `${PART_SEPARATOR}${delta}` : delta)] + } + if (field !== TEXT_FIELD || (kind !== 'reasoning' && kind !== 'agentMessage')) { + return [] + } + this.sentText.set(itemId, (this.sentText.get(itemId) ?? 0) + delta.length) + return [kind === 'reasoning' ? thoughtText(delta) : agentText(delta)] + } + + private remainingText( + itemId: string, + text: string | undefined, + wrap: (text: string) => SessionUpdate, + ): SessionUpdate[] { + if (text === undefined || this.summaryParts.has(itemId)) { + return [] + } + const sent = this.sentText.get(itemId) ?? 0 + if (text.length <= sent) { + return [] + } + this.sentText.set(itemId, text.length) + return [wrap(text.slice(sent))] + } + + private toolUpdates(item: ItemSnapshot, isCompleted: boolean): SessionUpdate[] { + const tool = item.kind === 'userShell' ? USER_SHELL_TOOL : (item.tool ?? item.kind) + const args = parseArguments(item.args) + const title = + item.kind === 'subagent' + ? `${toolName('subagent_spawn')}: ${item.objective ?? item.role ?? ''}` + : withPrice(toolTitle(tool, args), item.paid) + const status = toolStatus(item.status, isCompleted) + const updates: SessionUpdate[] = [] + if (!this.announced.has(item.itemId)) { + this.announced.add(item.itemId) + updates.push({ + sessionUpdate: 'tool_call', + toolCallId: item.itemId, + title, + kind: item.kind === 'subagent' ? 'other' : toolKind(tool), + status, + locations: toolLocations(args, this.cwd), + rawInput: args ?? item.args, + }) + if (!isCompleted) { + return updates + } + } + const content = isCompleted + ? toolContent(item, this.toolOutput.get(item.itemId) ?? '', this.cwd) + : undefined + updates.push({ + sessionUpdate: 'tool_call_update', + toolCallId: item.itemId, + status, + ...(content !== undefined && { content }), + ...(item.kind === 'subagent' && item.result !== undefined && { rawOutput: item.result }), + }) + if (isCompleted) { + this.toolOutput.delete(item.itemId) + } + return updates + } + + public updates(event: AgentEvent): SessionUpdate[] { + switch (event.type) { + case 'itemStarted': + case 'itemUpdated': { + return this.itemUpdates(event.item, false) + } + case 'itemCompleted': { + return this.itemUpdates(event.item, true) + } + case 'textDelta': { + return this.deltaUpdates(event.itemId, event.field, event.delta) + } + case 'todoChanged': { + return [{ sessionUpdate: 'plan', entries: planEntries(event.items) }] + } + case 'sessionNamed': { + return [{ sessionUpdate: 'session_info_update', title: event.name }] + } + case 'contextUsage': { + return event.windowTokens === undefined + ? [] + : [{ sessionUpdate: 'usage_update', used: event.usedTokens, size: event.windowTokens }] + } + case 'backendNotice': { + return [agentText(`${event.text}${PART_SEPARATOR}`)] + } + default: { + return [] + } + } + } + + /** A whole item: the text not yet sent, or a tool call announced or brought up to date. */ + public itemUpdates(item: ItemSnapshot, isCompleted: boolean): SessionUpdate[] { + this.kinds.set(item.itemId, item.kind) + switch (item.kind) { + case 'agentMessage': { + return this.remainingText(item.itemId, item.text, agentText) + } + case 'reasoning': { + const text = item.text ?? (item.summary ?? []).join(PART_SEPARATOR) + return this.remainingText(item.itemId, text, thoughtText) + } + case 'userMessage': { + return this.isReplay ? this.remainingText(item.itemId, item.text, userText) : [] + } + case 'toolCall': + case 'userShell': + case 'subagent': { + return this.toolUpdates(item, isCompleted) + } + default: { + return [] + } + } + } +} + +function agentText(text: string): SessionUpdate { + return { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } +} + +function thoughtText(text: string): SessionUpdate { + return { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } } +} + +function userText(text: string): SessionUpdate { + return { sessionUpdate: 'user_message_chunk', content: { type: 'text', text } } +} + +// --- approvals --------------------------------------------------------------- + +function isApproval(choice: ApprovalChoice): boolean { + return choice.decision.startsWith(APPROVED_DECISION_PREFIX) +} + +function permissionKind(choice: ApprovalChoice): PermissionOptionKind { + const isOnce = choice.scope === ONCE_SCOPE + if (isApproval(choice)) { + return isOnce ? 'allow_once' : 'allow_always' + } + return isOnce ? 'reject_once' : 'reject_always' +} + +/** The backend's choices as the client's options, each by its own id and label. */ +export function permissionOptions(choices: readonly ApprovalChoice[]): PermissionOption[] { + return choices.map((choice) => ({ + optionId: choice.choiceId, + name: choice.label, + kind: permissionKind(choice), + })) +} + +/** + * The choice to decide with (D62): the one the client picked when it was + * offered, and otherwise (cancelled, unknown, no answer) the backend's own + * deny choice, preferring "this once". Undefined only when the backend + * offered no way to deny, and then the caller stops the turn instead. + */ +export function decidedChoice( + response: RequestPermissionResponse | undefined, + choices: readonly ApprovalChoice[], +): ApprovalChoice | undefined { + const outcome = response?.outcome + const picked = + outcome?.outcome === 'selected' + ? choices.find((choice) => choice.choiceId === outcome.optionId) + : undefined + if (picked !== undefined) { + return picked + } + const denials = choices.filter((choice) => !isApproval(choice)) + return denials.find((choice) => choice.scope === ONCE_SCOPE) ?? denials[0] +} + +// The client's answer to `session/request_permission`, checked before use +// (AGENTS.md rule 7): the ACP SDK checks what it receives, not what a +// request of ours gets back. +const permissionResponseSchema = z.object({ + outcome: z.union([ + z.object({ outcome: z.literal('cancelled') }), + z.object({ outcome: z.literal('selected'), optionId: z.string() }), + ]), +}) + +/** The answer as the agent reads it: anything that is not one is a cancel, so nothing runs by it. */ +export function permissionResponse(raw: unknown): RequestPermissionResponse { + const parsed = permissionResponseSchema.safeParse(raw) + return parsed.success ? parsed.data : { outcome: { outcome: 'cancelled' } } +} + +/** What the approval is about, in one line: the command, the file, the host or the tool. */ +function subjectDetail(subject: ApprovalSubject): string | undefined { + const stages = subject.stages?.map((stage) => stage.argv.join(' ')).join(' ; ') + return subject.command ?? stages ?? subject.path ?? subject.host ?? subject.target +} + +/** The tool call a permission request is about, as the client shows it. */ +export function approvalToolCall( + event: Extract, + cwd: string, +): ToolCallUpdate { + const args = parseArguments(event.rawArgs) + const detail = subjectDetail(event.subject) + const name = toolName(event.toolName) + return { + toolCallId: event.itemId, + // A paid call never gets an approval (M58, PLAN.md D48): its price is in + // the paid-use question (paid.ts) and on its row. + title: detail === undefined ? toolTitle(event.toolName, args) : `${name}: ${detail}`, + kind: toolKind(event.toolName), + status: 'pending', + locations: toolLocations(args, cwd), + rawInput: args ?? event.rawArgs, + } +} + +// --- prompts ----------------------------------------------------------------- + +export type PromptResult = + | { readonly ok: true; readonly parts: TurnPart[]; readonly displayText: string } + | { readonly ok: false; readonly reason: string } + +/** A file the client attached, as context the model reads (clipped like a selection). */ +function attachedContext(uri: string, text: string): string { + const body = + text.length > SELECTION_TEXT_MAX_CHARS ? text.slice(0, SELECTION_TEXT_MAX_CHARS) : text + const tag = IDE_CONTEXT_TAGS.attachedContext + return `<${tag} uri="${uri}">\n${body}\n` +} + +/** A `file:` link inside the folder as an @mention, anything else as its URI. */ +function linkText(uri: string, cwd: string): string { + if (!uri.startsWith(FILE_SCHEME)) { + return uri + } + let absolute: string + try { + absolute = fileURLToPath(uri) + } catch { + // A `file:` URI this platform cannot map to a path (another host's share). + return uri + } + const relative = path.relative(cwd, absolute) + return relative.startsWith('..') || path.isAbsolute(relative) + ? uri + : formatMention(relative.split(path.sep).join('/')) +} + +function imagePart(base64Data: string): TurnPart | string { + const bytes = Buffer.from(base64Data, 'base64') + if (bytes.byteLength > MAX_IMAGE_BYTES) { + return UI_TEXT.attachmentTooLarge + } + const info = readImageInfo(bytes) + return info === undefined + ? UI_TEXT.attachmentUnsupported + : { + type: 'image', + base64Data, + mediaType: info.mediaType, + width: info.width, + height: info.height, + } +} + +/** One content block as a turn part; a string is why it was refused. */ +function blockPart(block: ContentBlock, cwd: string): TurnPart | string { + switch (block.type) { + case 'text': { + return { type: 'text', text: block.text } + } + case 'image': { + return imagePart(block.data) + } + case 'resource_link': { + return { type: 'text', text: linkText(block.uri, cwd) } + } + case 'resource': { + const { resource } = block + return 'text' in resource + ? { type: 'text', text: attachedContext(resource.uri, resource.text) } + : imagePart(resource.blob) + } + default: { + return UI_TEXT.attachmentUnsupported + } + } +} + +/** + * A prompt's blocks as the turn's parts; the first block the backend cannot + * take refuses the whole prompt, so nothing is sent half. `displayText` is + * what the user typed, for the stored transcript. + */ +export function promptParts(blocks: readonly ContentBlock[], cwd: string): PromptResult { + const parts: TurnPart[] = [] + for (const block of blocks) { + const part = blockPart(block, cwd) + if (typeof part === 'string') { + return { ok: false, reason: part } + } + parts.push(part) + } + const displayText = blocks + .flatMap((block) => (block.type === 'text' ? [block.text] : [])) + .join(PART_SEPARATOR) + return { ok: true, parts, displayText } +} + +/** The editor's MCP servers the backend can run, and the names of those it cannot. */ +export interface ForwardedMcpServers { + readonly servers: Readonly> + readonly skipped: readonly string[] +} + +function pairs(entries: readonly { readonly name: string; readonly value: string }[]) { + return Object.fromEntries(entries.map((entry) => [entry.name, entry.value])) +} + +/** + * An ACP client's MCP servers as the engine's (M63c): stdio and HTTP, keyed + * by name. SSE and the unstable ACP transport are left out, as is a second + * server under a name already taken. + */ +export function mcpServersFrom(requested: readonly McpServer[]): ForwardedMcpServers { + const servers = new Map() + const skipped: string[] = [] + for (const server of requested) { + if (servers.has(server.name)) { + skipped.push(server.name) + continue + } + // A stdio server has no `type` in the schema; some clients send `stdio` anyway. + if ('command' in server) { + servers.set(server.name, { + command: server.command, + args: server.args, + env: pairs(server.env), + }) + } else if (server.type === 'http') { + servers.set(server.name, { url: server.url, headers: pairs(server.headers) }) + } else { + skipped.push(server.name) + } + } + return { servers: Object.fromEntries(servers), skipped } +} diff --git a/src/core/agent/agentBackend.ts b/src/core/agent/agentBackend.ts index 42551801..2709c277 100644 --- a/src/core/agent/agentBackend.ts +++ b/src/core/agent/agentBackend.ts @@ -173,12 +173,21 @@ export interface SessionMcpHttpServer { readonly headers: Readonly> } +/** A per-session MCP server the host starts as a child process (MSP `stdio`; M63c, an ACP client's). */ +export interface SessionMcpStdioServer { + readonly command: string + readonly args: readonly string[] + readonly env: Readonly> +} + +export type SessionMcpServer = SessionMcpHttpServer | SessionMcpStdioServer + export interface StartSessionOptions { readonly workspaceRoot: string readonly modelId: string readonly approvalMode: string - /** IDE tool servers, keyed by name; needs the `sessionMcp` grant. */ - readonly mcpServers?: Readonly> + /** Tool servers, keyed by name: the IDE's, or an ACP client's; needs the `sessionMcp` grant. */ + readonly mcpServers?: Readonly> /** A new conversation kept in a side-chat surface (M53). */ readonly sideChat?: boolean } @@ -420,7 +429,7 @@ export interface AgentHost { resumeSession( sessionId: string, modelId: string, - mcpServers?: Readonly>, + mcpServers?: Readonly>, options?: { readonly requireSideChat?: boolean }, ): Promise forkSession( diff --git a/src/core/agent/approvalRules.ts b/src/core/agent/approvalRules.ts new file mode 100644 index 00000000..73047efd --- /dev/null +++ b/src/core/agent/approvalRules.ts @@ -0,0 +1,42 @@ +// The one approval a client answers without asking (PLAN.md D24): in "Edit +// automatically", a plain file write, allowed once. Never a protected +// write, an escalation, a staged command, a request replayed from history +// (M46) or anything in another mode. The panel's controller and the ACP +// agent (D62) both ask this module, so the rule exists once; the panel +// adds its own condition (one panel holding the session). + +import type { AgentEvent, ApprovalChoice } from '../../shared/agentEvents' +import type { PermissionMode } from '../../shared/constants' + +const EDIT_AUTOMATICALLY_MODE: PermissionMode = 'acceptEdits' +// Approval subjects that are a plain file write: the Model API's own, and +// Muse Code's `fileAccess` with write access (MSP `ApprovalSubject`). +const FILE_WRITE_SUBJECT = 'fileWrite' +const FILE_ACCESS_SUBJECT = 'fileAccess' +const WRITE_ACCESS = 'write' +const APPROVED_DECISION = 'approved' +const ONCE_SCOPE = 'once' + +/** The allow-once choice "Edit automatically" takes; undefined for anything the user must see. */ +export function editAutomaticallyChoice( + event: Extract, + mode: PermissionMode, +): ApprovalChoice | undefined { + if ( + mode !== EDIT_AUTOMATICALLY_MODE || + event.isReplayed === true || + event.isProtectedWrite || + event.isJudgeEscalated + ) { + return undefined + } + const { subject } = event + const isFileWrite = + subject.kind === FILE_WRITE_SUBJECT || + (subject.kind === FILE_ACCESS_SUBJECT && subject.access === WRITE_ACCESS) + return isFileWrite && subject.stages === undefined + ? event.availableChoices.find( + (choice) => choice.decision === APPROVED_DECISION && choice.scope === ONCE_SCOPE, + ) + : undefined +} diff --git a/src/core/backends/modelapi/ModelApiHost.ts b/src/core/backends/modelapi/ModelApiHost.ts index 10f8a1a5..82087048 100644 --- a/src/core/backends/modelapi/ModelApiHost.ts +++ b/src/core/backends/modelapi/ModelApiHost.ts @@ -247,8 +247,15 @@ export interface ModelApiPaidHooks { /** * The popup before a paid use (M58, PLAN.md D48): true when it is allowed * always in this workspace or allowed now. `requiresAsking` asks even then. + * `sessionId` is the conversation the use is for (a child task's parent), + * so a host serving several conversations to one client, as the ACP + * agent's does (D62), asks in the right one. */ - readonly allowsPaidUse: (request: PaidUseRequest, requiresAsking: boolean) => Promise + readonly allowsPaidUse: ( + request: PaidUseRequest, + requiresAsking: boolean, + sessionId: string, + ) => Promise /** Whether the feature is allowed always in this workspace, asking nothing. */ readonly isPaidUseRemembered: (feature: PaidFeature) => boolean /** Child token cost is a subset of the parent's conversation estimate. */ @@ -1561,7 +1568,10 @@ export class ModelApiSession implements AgentSession { } return this.isSubagent ? this.childTaskGrant?.isWebSearchAllowed === true - : await unlessStopped(this.deps.allowsPaidUse({ feature: 'webSearch' }, false), signal) + : await unlessStopped( + this.deps.allowsPaidUse({ feature: 'webSearch' }, false, this.askingSessionId), + signal, + ) } /** The IDE tool or MCP server tool a function name is, when it is one (M50). */ @@ -2330,7 +2340,7 @@ export class ModelApiSession implements AgentSession { const stopNotifying = this.notifyWhileAsking(call, signal) try { const isAllowed = await unlessStopped( - this.deps.allowsPaidUse(question.paid, requiresUserApproval), + this.deps.allowsPaidUse(question.paid, requiresUserApproval, this.askingSessionId), signal, ) return { isApproved: isAllowed, feedback: undefined } @@ -2796,13 +2806,15 @@ export class ModelApiSession implements AgentSession { limit, seen: this.seenFiles, }) - const moved = Promise.withResolvers() - this.foregroundShells.set(itemId, () => { - moved.resolve(undefined) + // Not `Promise.withResolvers`: VS Code 1.99 and 1.100 run Node 20 (PLAN.md M62). + const moved = new Promise((resolve) => { + this.foregroundShells.set(itemId, () => { + resolve(undefined) + }) }) let finished: ToolOutcome | undefined try { - finished = await Promise.race([running, moved.promise]) + finished = await Promise.race([running, moved]) } finally { this.foregroundShells.delete(itemId) turnSignal.removeEventListener('abort', onTurnStop) @@ -3077,6 +3089,7 @@ export class ModelApiSession implements AgentSession { }, }, false, + this.askingSessionId, ) if (!isAccepted) { throw new ChildTaskRefusedError('consentDeclined') @@ -4884,6 +4897,11 @@ export class ModelApiSession implements AgentSession { return submission } + /** The conversation a paid use is asked in (M58): a child task's is its parent's. */ + private get askingSessionId(): string { + return this.parentSession?.askingSessionId ?? this.sessionId + } + // --- AgentSession --- /** SessionStart runs when the session opens; context enters its first turn. */ diff --git a/src/core/backends/modelapi/client.ts b/src/core/backends/modelapi/client.ts index f365fdca..2fe49b41 100644 --- a/src/core/backends/modelapi/client.ts +++ b/src/core/backends/modelapi/client.ts @@ -25,7 +25,11 @@ import { import { fill } from '../../../shared/l10n/text' import { DeadlineError, withDeadline } from '../../timeouts' import type { CoreLogger } from '../../logging' -import { describeNetworkFailure, networkFailureMessage } from '../../networkFailure' +import { + describeNetworkFailure, + type NetworkAdvice, + networkFailureMessage, +} from '../../networkFailure' import { type CreateImageBody, type EditImageBody, @@ -54,6 +58,11 @@ export interface ModelApiClientDeps { readonly log: CoreLogger /** How long a reply stream may send nothing; the constant unless a test shortens it. */ readonly streamIdleMs?: number + /** + * Whose settings a request that never reached Meta names (M56): VS Code's + * unless the ACP agent says its own (PLAN.md D62, Q66). + */ + readonly networkAdvice?: NetworkAdvice } export class ModelApiError extends Error { @@ -324,7 +333,7 @@ export class ModelApiClient { } // Never reached the server: its causes say why, and the message // names the setting or store to check (M56, PLAN.md D43). - const reason = networkFailureMessage(error) + const reason = networkFailureMessage(error, this.deps.networkAdvice) if (isRateLimitOnly || attempt >= MODEL_API_MAX_RETRIES) { throw new ModelApiError(reason, NETWORK_FAILURE_STATUS, undefined, undefined) } diff --git a/src/core/backends/musecode/MuseCodeHost.ts b/src/core/backends/musecode/MuseCodeHost.ts index 27a11687..07316f1b 100644 --- a/src/core/backends/musecode/MuseCodeHost.ts +++ b/src/core/backends/musecode/MuseCodeHost.ts @@ -57,7 +57,7 @@ import type { SessionEventListener, SessionHistoryOutcome, SessionListEvent, - SessionMcpHttpServer, + SessionMcpServer, SessionPage, SkillSummary, StartSessionOptions, @@ -1103,7 +1103,7 @@ export class MuseCodeHost implements AgentHost { } private mcpConfig( - mcpServers: Readonly> | undefined, + mcpServers: Readonly> | undefined, ): Record { if (mcpServers === undefined) { return {} @@ -1114,12 +1114,20 @@ export class MuseCodeHost implements AgentHost { Object.entries(mcpServers).map(([name, server]) => [ name, // `optional`: a tool-server hiccup never blocks the session. - { - transport: 'streamableHttp', - url: server.url, - headers: server.headers, - mode: 'optional', - }, + 'command' in server + ? { + transport: 'stdio', + command: server.command, + args: server.args, + env: server.env, + mode: 'optional', + } + : { + transport: 'streamableHttp', + url: server.url, + headers: server.headers, + mode: 'optional', + }, ]), ), }, @@ -1216,7 +1224,7 @@ export class MuseCodeHost implements AgentHost { public async resumeSession( sessionId: string, modelId: string, - mcpServers?: Readonly>, + mcpServers?: Readonly>, ): Promise { const { loaded, hasPending } = await this.opened(async () => { const envelope = sessionEnvelopeSchema.parse( diff --git a/src/core/networkFailure.ts b/src/core/networkFailure.ts index dc99dee2..9ce17f2a 100644 --- a/src/core/networkFailure.ts +++ b/src/core/networkFailure.ts @@ -7,6 +7,10 @@ // the likely fix (which VS Code setting, which store) and keeps the // technical detail beside it. Pure; no `vscode` import. The shapes are the // ones Node 24 throws (captured 2026-09-25, docs/certification/m56.md). +// +// The ACP agent (PLAN.md D62, Q66) runs outside VS Code, where its +// settings do not reach: there the same failure names the variables the +// agent's own environment takes instead. The host says which it is. import { CONNECTION_ERROR_CODES, @@ -22,6 +26,12 @@ import { redactSecrets } from './redact' export type NetworkFailureKind = 'certificate' | 'proxyCredentials' | 'proxyRefused' | 'unreachable' | 'other' +/** + * Whose settings the advice names: VS Code's `http.*` settings in the + * extension, the agent's environment variables in the ACP agent. + */ +export type NetworkAdvice = 'vscode' | 'agent' + export interface NetworkFailure { readonly kind: NetworkFailureKind /** The error and its causes, "fetch failed: connect ECONNREFUSED … (ECONNREFUSED)". */ @@ -100,20 +110,21 @@ export function describeNetworkFailure(error: unknown): NetworkFailure { } /** The advice for a kind, read when shown (PLAN.md D33); none for an unrecognised failure. */ -function adviceFor(failure: NetworkFailure): string | undefined { +function adviceFor(failure: NetworkFailure, advice: NetworkAdvice): string | undefined { + const isAgent = advice === 'agent' switch (failure.kind) { case 'certificate': { - return UI_TEXT.networkUntrustedCertificate + return isAgent ? UI_TEXT.acpNetworkUntrustedCertificate : UI_TEXT.networkUntrustedCertificate } case 'proxyCredentials': { - return UI_TEXT.networkProxyCredentials + return isAgent ? UI_TEXT.acpNetworkProxyCredentials : UI_TEXT.networkProxyCredentials } case 'proxyRefused': { // A status code, not a quantity: never grouped or localised. return fill(UI_TEXT.networkProxyRefused, { status: String(failure.proxyStatus) }) } case 'unreachable': { - return UI_TEXT.networkUnreachable + return isAgent ? UI_TEXT.acpNetworkUnreachable : UI_TEXT.networkUnreachable } case 'other': { return undefined @@ -121,9 +132,13 @@ function adviceFor(failure: NetworkFailure): string | undefined { } } -/** The advice, with the technical detail in parentheses; the detail alone when there is none. */ -export function networkFailureMessage(error: unknown): string { +/** + * The advice, with the technical detail in parentheses; the detail alone + * when there is none. A proxy's refusal names no setting, so both hosts + * share it. + */ +export function networkFailureMessage(error: unknown, advice: NetworkAdvice = 'vscode'): string { const failure = describeNetworkFailure(error) - const advice = adviceFor(failure) - return advice === undefined ? failure.detail : `${advice} (${failure.detail})` + const text = adviceFor(failure, advice) + return text === undefined ? failure.detail : `${text} (${failure.detail})` } diff --git a/src/core/paid/paidConsent.ts b/src/core/paid/paidConsent.ts index caa9abcc..78c31421 100644 --- a/src/core/paid/paidConsent.ts +++ b/src/core/paid/paidConsent.ts @@ -11,15 +11,79 @@ // takes it back. A use that no longer asks is still loud: its row is marked // paid, the badge names the feature, and the tally counts it. // -// No `vscode` here: the host injects the popup and the stores. +// No `vscode` here: the host injects the popup and the stores. The popup's +// words are here, so VS Code's modal and the ACP agent's permission request +// (D62) say the same. -import { PAID_FEATURES, type PaidFeature } from '../../shared/constants' -import type { PaidUseRequest } from '../../shared/paid' +import { PAID_FEATURES, type PaidFeature, UI_TEXT } from '../../shared/constants' +import { fill } from '../../shared/l10n/text' +import { + paidFeaturePrice, + type PaidUseRequest, + scheduledRunPrice, + subagentTaskPrice, +} from '../../shared/paid' import type { CoreLogger } from '../logging' /** The popup's three answers. */ export type PaidUseAnswer = 'once' | 'always' | 'deny' +/** The popup's question and what it says about the use, in the display language. */ +export function paidUseQuestion(request: PaidUseRequest): { + readonly title: string + readonly detail: string +} { + switch (request.feature) { + case 'webSearch': { + return { + title: UI_TEXT.paidUseWebSearchTitle, + detail: fill(UI_TEXT.paidUseWebSearchDetail, { price: paidFeaturePrice('webSearch') }), + } + } + case 'voice': { + return { + title: UI_TEXT.paidUseVoiceTitle, + detail: fill(UI_TEXT.paidUseVoiceDetail, { price: paidFeaturePrice('voice') }), + } + } + case 'imageGeneration': { + // Every image, whatever the backend or permission mode: what is made, + // from what, and that the key pays for it (M34, M44). + const sources = request.sources.join(', ') + return { + title: fill(request.kind === 'edit' ? UI_TEXT.imageBuyEditTitle : UI_TEXT.imageBuyTitle, { + path: request.path, + }), + detail: [ + fill(UI_TEXT.imageBuyPrompt, { prompt: request.prompt }), + ...(sources === '' ? [] : [fill(UI_TEXT.imageBuySources, { paths: sources })]), + fill(UI_TEXT.imageBuyBilling, { price: paidFeaturePrice('imageGeneration') }), + ].join('\n\n'), + } + } + case 'scheduledPrompts': { + return { + title: fill(UI_TEXT.scheduleRunConfirmTitle, { model: request.modelId }), + detail: [ + fill(UI_TEXT.scheduleRunConfirmPrompt, { prompt: request.prompt }), + fill(UI_TEXT.scheduleRunConfirmPrice, { price: scheduledRunPrice(request.modelId) }), + UI_TEXT.scheduleRunConfirmExtras, + ].join('\n\n'), + } + } + case 'subagents': { + const { task } = request + return { + title: fill(UI_TEXT.paidSubagentTaskTitle, { role: task.role }), + detail: fill(UI_TEXT.paidSubagentTaskDetail, { + objective: task.objective, + price: subagentTaskPrice(task.modelId, task.attemptLimit), + }), + } + } + } +} + export interface PaidUseConsentDeps { /** Whether the feature may be used at all: its setting on and its price accepted. */ readonly isOn: (feature: PaidFeature) => boolean @@ -44,6 +108,23 @@ export class PaidUseConsent { } } + /** + * Keeps "always" for the feature. A store that cannot be written is + * logged and leaves this use allowed once, so the next one asks again + * instead of this one failing. + */ + private async remember(feature: PaidFeature): Promise { + try { + await this.deps.writeGrants(new Set([...this.deps.readGrants(), feature])) + return true + } catch (error: unknown) { + this.deps.log.warn( + `Paid use of ${feature}: "always" could not be kept, so it is allowed once: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + } + public onDidChange(listener: () => void): () => void { this.listeners.add(listener) return () => { @@ -86,8 +167,12 @@ export class PaidUseConsent { this.deps.log.info(`Paid use of ${feature}: turned off while the popup was open`) return false } - if (answer === 'always' && canRemember && this.deps.canRemember()) { - await this.deps.writeGrants(new Set([...this.deps.readGrants(), feature])) + if ( + answer === 'always' && + canRemember && + this.deps.canRemember() && + (await this.remember(feature)) + ) { this.deps.log.info(`Paid use of ${feature}: allowed always in this workspace`) this.notify() } else { diff --git a/src/core/support/report.ts b/src/core/support/report.ts index d73cdd37..b40ff6f2 100644 --- a/src/core/support/report.ts +++ b/src/core/support/report.ts @@ -12,6 +12,7 @@ import { MUSE_CONFIG_STATUS_MAX_CHARS, MUSE_KEYCHAIN_SERVICE, PRODUCT_NAME, + VSCODE_WEBSOCKET_ROUTED_SINCE, } from '../../shared/constants' import type { CliSignIn, @@ -19,10 +20,18 @@ import type { KeychainItemPresence, } from '../backends/musecode/credentialFile' +/** + * Whether the editor's extension host installed its proxy-aware version of a + * global (M62): `absent` when the host has no such global at all, as Node 20 + * (VS Code 1.99 and 1.100) has no WebSocket. + */ +export type HostRouting = 'routed' | 'notRouted' | 'absent' + /** * The network the extension's own requests and Muse Code's run under (M56). * The extension's `fetch` and WebSocket go through VS Code's proxy support - * and system certificates while VS Code's settings allow it. + * and system certificates where the editor routes them and VS Code's + * settings allow it. */ export interface NetworkFacts { /** `http.proxy` is set; its value is not shown. */ @@ -36,6 +45,9 @@ export interface NetworkFacts { /** `http.fetchAdditionalSupport` and `http.webSocketAdditionalSupport`. */ readonly isFetchSupportOn: boolean readonly isWebSocketSupportOn: boolean + /** Whether this editor routes each global at all; the settings above matter only then (M62). */ + readonly fetchRouting: HostRouting + readonly webSocketRouting: HostRouting /** HTTPS_PROXY / HTTP_PROXY in the extension host's environment, in either case. */ readonly hasEnvironmentProxy: boolean /** NODE_EXTRA_CA_CERTS names extra roots for the extension host. */ @@ -135,11 +147,39 @@ function yesNo(isTrue: boolean): string { return isTrue ? YES : NO } +/** + * A global's route: its setting where the editor routes it, and otherwise + * why not, so the report never claims a proxy the request does not use (M62). + */ +function routeState(isSettingOn: boolean, routing: HostRouting, notRouted: string): string { + switch (routing) { + case 'routed': { + return yesNo(isSettingOn) + } + case 'notRouted': { + return notRouted + } + case 'absent': { + return 'none in this extension host' + } + } +} + function networkLines(network: NetworkFacts): readonly string[] { + const fetchRoute = routeState( + network.isFetchSupportOn, + network.fetchRouting, + 'no (this editor does not route it)', + ) + const webSocketRoute = routeState( + network.isWebSocketSupportOn, + network.webSocketRouting, + `no (this editor does not route it; VS Code does from ${VSCODE_WEBSOCKET_ROUTED_SINCE})`, + ) return [ `network: http.proxy set: ${yesNo(network.isProxySet)}; proxySupport: ${network.proxySupport}; proxyStrictSSL: ${yesNo(network.isProxyStrictSsl)}; proxyAuthorization set: ${yesNo(network.isProxyAuthorizationSet)}; noProxy entries: ${String(network.noProxyCount)}; proxy in environment: ${yesNo(network.hasEnvironmentProxy)}`, `certificates: system certificates: ${yesNo(network.isSystemCertificatesOn)}; NODE_EXTRA_CA_CERTS: ${yesNo(network.hasExtraCaCertificates)}`, - `extension requests through VS Code's network support: fetch ${yesNo(network.isFetchSupportOn)}, WebSocket ${yesNo(network.isWebSocketSupportOn)}`, + `extension requests through VS Code's network support: fetch ${fetchRoute}, WebSocket ${webSocketRoute}`, `muse serve: proxy from ${MUSE_PROXY_SOURCES[network.museProxySource]}; SSL_CERT_FILE or SSL_CERT_DIR: ${yesNo(network.hasMuseCertificateOverride)}`, ] } diff --git a/src/core/voice/dictation.ts b/src/core/voice/dictation.ts index 1cf87556..71bef02f 100644 --- a/src/core/voice/dictation.ts +++ b/src/core/voice/dictation.ts @@ -133,6 +133,14 @@ interface RunningHelper { /** What the conversation controller drives: the three calls, nothing else. */ export type DictationHandle = Pick +/** What the host found for a window: a way to start dictating, or why there is none. */ +export type DictationSetup = + | { + readonly isAvailable: true + readonly create: (listener: DictationListener) => DictationHandle + } + | { readonly isAvailable: false; readonly reason: string } + export class Dictation { private helper: RunningHelper | undefined private status: DictationStatus = 'idle' diff --git a/src/extension.ts b/src/extension.ts index b0260bfc..388c4809 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -96,7 +96,8 @@ import { readSettings, toSettingsSnapshot } from './host/settings' import { ChatViewProvider, SIDEBAR_SURFACE_ID } from './host/views/ChatViewProvider' import { openChatPanel, restoreChatPanel } from './host/views/chatPanel' import { SurfaceRegistry } from './host/views/surfaceRegistry' -import type { ChatSurface, WebviewHostContext } from './host/views/webviewSetup' +import type { ChatSurface } from './host/views/chatSurface' +import type { WebviewHostContext } from './host/views/webviewSetup' import { loadUiTable } from './host/l10n' import { createInsightsReader } from './host/usage/traceLogs' import { createDictationSetup, createMuseVoiceSetup } from './host/voice/dictationHost' @@ -1746,6 +1747,7 @@ export async function activate(context: vscode.ExtensionContext): Promise process.env, process.platform, backend, + globalThis, ), managedConfiguration: managed, homeDir: homedir(), diff --git a/src/host/auth/accountHost.ts b/src/host/auth/accountHost.ts index b774a86f..d26fa01c 100644 --- a/src/host/auth/accountHost.ts +++ b/src/host/auth/accountHost.ts @@ -38,8 +38,6 @@ export interface AccountSession { /** Starts one short-lived host; `signal` cancels the start. */ export type ConnectAccountHost = (signal: AbortSignal) => Promise -const CANCELLED_CONNECT = Symbol('cancelled account host connection') - /** The error's name only: a CLI message can carry a path or an account. */ function errorName(error: unknown): string { return error instanceof Error ? error.name : 'unknown error' @@ -225,16 +223,9 @@ export async function connectAccountSession( isStderrReported = true }, }) - const cancelledConnect = Promise.withResolvers() - const onAbort = () => { - cancelledConnect.resolve(CANCELLED_CONNECT) - } - signal.addEventListener('abort', onAbort, { once: true }) - if (isAborted()) { - onAbort() - } try { - const spawned = await Promise.race([ + // Not `Promise.withResolvers`, which Node 20 lacks (PLAN.md M62). + const spawned = await unlessAborted( withDeadline( handshake.initialize({ clientInfo: { name: MSP_CLIENT_NAME, version: extensionVersion }, @@ -243,9 +234,9 @@ export async function connectAccountSession( MSP_HANDSHAKE_TIMEOUT_MS, 'The Muse Code account host did not start in time', ), - cancelledConnect.promise, - ]) - if (spawned === CANCELLED_CONNECT || isAborted()) { + signal, + ) + if (spawned === undefined || isAborted()) { throw new Error('The Muse Code account host was cancelled') } if (!spawned.initializeResult.experimentalApi) { @@ -255,7 +246,5 @@ export async function connectAccountSession( } catch (error: unknown) { await handshake.close() throw error - } finally { - signal.removeEventListener('abort', onAbort) } } diff --git a/src/host/auth/credentialStore.ts b/src/host/auth/credentialStore.ts index 434ffb75..dcb0925a 100644 --- a/src/host/auth/credentialStore.ts +++ b/src/host/auth/credentialStore.ts @@ -1,13 +1,14 @@ // The Model API key lives only in VS Code secret storage (OS keychain). The // dependency is the three-method subset of `vscode.SecretStorage`, which the -// real object satisfies structurally and tests replace with a Map. +// real object satisfies structurally, the ACP agent's OS credential store +// implements (PLAN.md D61), and tests replace with a Map. import { MODEL_API_KEY_PATTERN, SECRET_KEYS } from '../../shared/constants' export interface SecretStore { - get(key: string): Thenable - store(key: string, value: string): Thenable - delete(key: string): Thenable + get(key: string): PromiseLike + store(key: string, value: string): PromiseLike + delete(key: string): PromiseLike } export function isValidModelApiKey(candidate: string): boolean { @@ -21,6 +22,8 @@ export class CredentialStore { private readonly secrets: SecretStore, /** Where an unreadable secret store is reported, once. */ private readonly warn: (message: string) => void, + /** The store's name in that report: the ACP agent's is the OS store (D61). */ + private readonly storeName = "VS Code's secret storage", ) {} /** @@ -36,7 +39,7 @@ export class CredentialStore { if (!this.hasReportedFailure) { this.hasReportedFailure = true this.warn( - `VS Code's secret storage could not be read, so no Model API key is available: ${String(error)}`, + `${this.storeName} could not be read, so no Model API key is available: ${String(error)}`, ) } return undefined diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index 7c474390..05c6b7df 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -136,6 +136,36 @@ function endingOf(outcome: string): HostEnding | undefined { return { endedAs: shown } } +/** + * Settles with `value` once `signal` aborts (at once if it already has); + * `remove` stops listening. Not `Promise.withResolvers`: VS Code 1.99 and + * 1.100 run Node 20 (PLAN.md M62). + */ +function settleOnAbort( + signal: AbortSignal, + value: T, +): { readonly promise: Promise; readonly remove: () => void } { + const listening = new AbortController() + const promise = new Promise((resolve) => { + signal.addEventListener( + 'abort', + () => { + resolve(value) + }, + { once: true, signal: listening.signal }, + ) + if (signal.aborted) { + resolve(value) + } + }) + return { + promise, + remove: () => { + listening.abort() + }, + } +} + /** The returned URL is data from a CLI process: do not open arbitrary origins. */ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly code: string } { const parsed = loginStartSchema.parse(raw) @@ -201,10 +231,12 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise() + // waiting on stops. A controller of the flow's own, not + // `Promise.withResolvers`, which Node 20 lacks (PLAN.md M62). + const stopper = new AbortController() + const stopped = settleOnAbort(stopper.signal, STOPPED) const stop = () => { - stopped.resolve(STOPPED) + stopper.abort() } let hostEnding: HostEnding | undefined let isEnded = false @@ -348,6 +380,7 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise unknown) | undefined + /** Whose settings a failed request names: VS Code's unless the ACP agent says its own (Q66). */ + readonly networkAdvice?: NetworkAdvice | undefined + /** What a missing or damaged bundle says: reinstall the extension, unless the agent says its own. */ + readonly bundleUnavailable?: (() => string) | undefined } const MANAGER_DISPOSED = 'The Model API backend was stopped while it was starting' @@ -120,6 +125,11 @@ export class ModelApiBackendManager { return host } + /** The sentence a missing or damaged bundle shows, read when shown (D33). */ + private unavailableText(): string { + return this.deps.bundleUnavailable?.() ?? UI_TEXT.modelApiBundleUnavailable + } + /** The bundle's factory; a missing or corrupt file is logged and refused in the user's words. */ private loadBundle(): ModelApiBundle { const { bundlePath, loadBundle = requireFile } = this.deps @@ -130,14 +140,14 @@ export class ModelApiBackendManager { this.deps.log.error( `The Model API bundle ${bundlePath} could not be loaded: ${describe(error)}`, ) - throw new Error(UI_TEXT.modelApiBundleUnavailable, { cause: error }) + throw new Error(this.unavailableText(), { cause: error }) } if (!isModelApiBundle(loaded)) { this.deps.log.error(`${bundlePath} does not export the Model API backend's factory`) if (this.deps.loadBundle === undefined) { forgetFile(bundlePath) } - throw new Error(UI_TEXT.modelApiBundleUnavailable) + throw new Error(this.unavailableText()) } return loaded } @@ -159,6 +169,7 @@ export class ModelApiBackendManager { now: this.deps.now, random: this.deps.random, log: this.deps.log, + ...(this.deps.networkAdvice !== undefined && { networkAdvice: this.deps.networkAdvice }), }, host: { workspaceRoot, diff --git a/src/host/backend/toolIo.ts b/src/host/backend/toolIo.ts index d0842618..1c73fb4c 100644 --- a/src/host/backend/toolIo.ts +++ b/src/host/backend/toolIo.ts @@ -241,8 +241,12 @@ export function shellEnvironment( return clean } -/** Hooks get Muse Code's narrow environment; provider credentials never pass. */ -function isForbiddenHookEnv(name: string): boolean { +/** + * A provider credential's variable: any `*_API_KEY`, and the named ones. + * Hooks never get one (Muse Code's narrow environment), nor does any process + * the ACP agent starts but Muse Code's own (runtime/credentialVariables.ts). + */ +export function isCredentialVariable(name: string): boolean { const upper = name.toUpperCase() return upper.endsWith('_API_KEY') || HOOK_FORBIDDEN_ENV_NAMES.has(upper) } @@ -258,7 +262,7 @@ export function hookEnvironment( ? [...HOOK_ENV_NAMES, ...WINDOWS_HOOK_ENV_NAMES, ...extraNames] : [...HOOK_ENV_NAMES, ...extraNames] for (const name of names) { - if (isForbiddenHookEnv(name)) { + if (isCredentialVariable(name)) { continue } const value = environmentValue(env, platform, name) diff --git a/src/host/commands/focusInput.ts b/src/host/commands/focusInput.ts index e38a6a9b..9bfcd492 100644 --- a/src/host/commands/focusInput.ts +++ b/src/host/commands/focusInput.ts @@ -2,7 +2,7 @@ // Pure orchestration over injected dependencies so it is unit-tested without // VS Code. -import type { ChatSurface } from '../views/webviewSetup' +import type { ChatSurface } from '../views/chatSurface' export interface FocusInputDeps { /** Current value of the `museSpark.inputFocused` context key. */ diff --git a/src/host/commands/insertMention.ts b/src/host/commands/insertMention.ts index de40332f..72d3797d 100644 --- a/src/host/commands/insertMention.ts +++ b/src/host/commands/insertMention.ts @@ -3,7 +3,7 @@ import { formatMentionReference, type MentionSource } from '../../core/mention' import { UI_TEXT } from '../../shared/constants' -import type { ChatSurface } from '../views/webviewSetup' +import type { ChatSurface } from '../views/chatSurface' export interface InsertMentionDeps { /** The active text editor's file and selection, or undefined when none. */ diff --git a/src/host/conversation/conversationController.ts b/src/host/conversation/conversationController.ts index 1de7c060..0d60d2b2 100644 --- a/src/host/conversation/conversationController.ts +++ b/src/host/conversation/conversationController.ts @@ -27,13 +27,14 @@ import { type TurnPart, type TurnSubmission, } from '../../core/agent/agentBackend' +import { editAutomaticallyChoice } from '../../core/agent/approvalRules' import { toSessionRow } from '../../core/agent/sessionRows' import { isProfileWorkspace, type ShellSandboxPosture } from '../../core/backends/musecode/sandbox' import { chatReferenceText } from '../../core/chatReference' import { textFileDisplay } from '../../shared/textFileDisplay' import { type EditorContext, editorContextText } from '../../core/editorContext' import type { ToolImageResult } from '../../core/toolImages' -import type { DictationHandle, DictationStatus } from '../../core/voice/dictation' +import type { DictationHandle, DictationSetup, DictationStatus } from '../../core/voice/dictation' import { ALLOWED_LINK_SCHEMES, AUTH_REQUIRED_ERROR_KIND, @@ -96,8 +97,7 @@ import type { AccountFacts, SubscriptionUsage, UsageInsights } from '../../share import type { AuthPort } from '../auth/authService' import type { ReviewNotice } from '../editor/editReview' import { errorDetail, type Logger } from '../logger' -import type { ChatSurface, ConversationMessage } from '../views/webviewSetup' -import type { DictationSetup } from '../voice/dictationHost' +import type { ChatSurface, ConversationMessage } from '../views/chatSurface' import { type ConversationExports, exportConversation, @@ -298,16 +298,9 @@ const CANCELLED_STATUS = 'cancelled' const MISSING_RUN_REASON = 'missing_run' const BYPASS_MODE: PermissionMode = 'bypassPermissions' const FALLBACK_MODE: PermissionMode = 'manual' -const EDIT_AUTOMATICALLY_MODE: PermissionMode = 'acceptEdits' /** Auto approval is safe only while one controller holds the shared session. */ const sessionSurfaces = new WeakMap>() -// Approval subjects that are a plain file write: the Model API's own, and -// Muse Code's `fileAccess` with write access (MSP `ApprovalSubject`). -const FILE_WRITE_SUBJECT = 'fileWrite' -const FILE_ACCESS_SUBJECT = 'fileAccess' -const WRITE_ACCESS = 'write' const APPROVED_DECISION = 'approved' -const ONCE_SCOPE = 'once' const [IDE_MCP_CAPABILITY] = MSP_REQUESTED_CAPABILITIES const HISTORY_MODE_NONE = 'none' const REWIND_HISTORY_MODES: ReadonlySet = new Set([ @@ -853,25 +846,9 @@ export class ConversationController { private autoApprovalChoice( event: Extract, ): ApprovalChoice | undefined { - if ( - this.permissionMode !== EDIT_AUTOMATICALLY_MODE || - this.session === undefined || - sessionSurfaces.get(this.session)?.size !== 1 || - event.isReplayed === true || - event.isProtectedWrite || - event.isJudgeEscalated - ) { - return undefined - } - const { subject } = event - const isFileWrite = - subject.kind === FILE_WRITE_SUBJECT || - (subject.kind === FILE_ACCESS_SUBJECT && subject.access === WRITE_ACCESS) - return isFileWrite && subject.stages === undefined - ? event.availableChoices.find( - (choice) => choice.decision === APPROVED_DECISION && choice.scope === ONCE_SCOPE, - ) - : undefined + return this.session === undefined || sessionSurfaces.get(this.session)?.size !== 1 + ? undefined + : editAutomaticallyChoice(event, this.permissionMode) } /** Answers an edit approval on the user's behalf; shows the card if the host refuses. */ diff --git a/src/host/logger.ts b/src/host/logger.ts index 7ba97c02..b7410ce2 100644 --- a/src/host/logger.ts +++ b/src/host/logger.ts @@ -1,8 +1,8 @@ // Logging adapter over VS Code's LogOutputChannel that redacts secrets before // anything is written. Every host module logs through this interface, never -// through the channel or `console` directly. +// through the channel or `console` directly. The channel is taken by its +// shape, so the modules that log stay free of `vscode` (M61, PLAN.md D60). -import type * as vscode from 'vscode' import { redactSecrets } from '../core/redact' export interface Logger { @@ -28,7 +28,8 @@ export function logRejection(log: Logger, what: string): (error: unknown) => voi } } -export function createLogger(channel: vscode.LogOutputChannel): Logger { +/** `channel` is VS Code's `LogOutputChannel`, taken by the four methods it shares with `Logger`. */ +export function createLogger(channel: Logger): Logger { return { trace(message) { channel.trace(redactSecrets(message)) diff --git a/src/host/mention/mentionQuickPick.ts b/src/host/mention/mentionQuickPick.ts index f17a0b09..710ff0e9 100644 --- a/src/host/mention/mentionQuickPick.ts +++ b/src/host/mention/mentionQuickPick.ts @@ -51,19 +51,21 @@ export async function pickMentionFile(deps: MentionQuickPickDeps): Promise() + // Not `Promise.withResolvers`: VS Code 1.99 and 1.100 run Node 20 (PLAN.md M62). + const settled = new Promise((resolve) => { + picker.onDidAccept(() => { + resolve(picker.selectedItems[0]?.path) + picker.hide() + }) + picker.onDidHide(() => { + resolve(undefined) + picker.dispose() + }) + }) picker.onDidChangeValue((value) => { void refresh(value) }) - picker.onDidAccept(() => { - settled.resolve(picker.selectedItems[0]?.path) - picker.hide() - }) - picker.onDidHide(() => { - settled.resolve(undefined) - picker.dispose() - }) picker.show() await refresh('') - return await settled.promise + return await settled } diff --git a/src/host/networkPosture.ts b/src/host/networkPosture.ts index 8a6c51d2..85e3302e 100644 --- a/src/host/networkPosture.ts +++ b/src/host/networkPosture.ts @@ -1,17 +1,21 @@ -// The extension's network posture (M56, PLAN.md D43). VS Code 1.125 and -// later extend an extension's global `fetch` and `WebSocket` with its proxy -// support (`http.proxy`, the system proxy or a PAC file, proxy -// authentication, `http.noProxy`) and the operating system's certificates, -// while `http.fetchAdditionalSupport`, `http.webSocketAdditionalSupport`, +// The extension's network posture (M56, PLAN.md D43). VS Code extends an +// extension's global `fetch` and `WebSocket` with its proxy support +// (`http.proxy`, the system proxy or a PAC file, proxy authentication, +// `http.noProxy`) and the operating system's certificates, while +// `http.fetchAdditionalSupport`, `http.webSocketAdditionalSupport`, // `http.proxySupport` and `http.systemCertificates` allow it (read from VS // Code 1.125.0's `proxyResolver.ts` and 1.139.0's shipped extension host). -// So the Model API client and the Muse Voice socket use the globals as they -// stand at each call, and the Diagnostics report states the settings that -// decide it, never a proxy's address (it can hold a password). +// Not every version the manifest accepts does both (M62): `fetch` is routed +// from the 1.99 floor on, `WebSocket` only from 1.112.0, and an editor that +// does not run VS Code's extension host may route neither. So the Model API +// client and the Muse Voice socket use the globals as they stand at each +// call, and the Diagnostics report states whether this editor routes each +// and the settings that decide it, never a proxy's address (it can hold a +// password). import * as z from 'zod/mini' import { environmentValue } from '../core/backends/musecode/launch' -import type { NetworkFacts, SupportFacts } from '../core/support/report' +import type { HostRouting, NetworkFacts, SupportFacts } from '../core/support/report' import { HTTP_NO_PROXY_SETTING, HTTP_POSTURE_SETTINGS, @@ -20,6 +24,7 @@ import { HTTP_PROXY_SUPPORT_MODES, NODE_EXTRA_CA_CERTS_VARIABLE, PROXY_VARIABLE_SPELLINGS, + VSCODE_ROUTED_GLOBALS, } from '../shared/constants' import type { ProxySettings } from './backend/museCodeBackendManager' import type { ProcessResult } from './backend/sandboxSetup' @@ -66,12 +71,33 @@ function isTextSet(source: HttpSettingsSource, key: string): boolean { return parsed.success && parsed.data !== '' } -/** VS Code's defaults stand for a value it does not report or that is malformed. */ +/** + * Whether the extension host put its proxy-aware `fetch` or `WebSocket` in + * place (M62), told by the global VS Code's `proxyResolver.ts` sets beside + * it. A host without the marker is not claimed to route the global. + */ +export function hostRouting( + globals: object, + global: keyof typeof VSCODE_ROUTED_GLOBALS, +): HostRouting { + const { name, marker } = VSCODE_ROUTED_GLOBALS[global] + const value: unknown = Reflect.get(globals, name) + if (typeof value !== 'function') { + return 'absent' + } + return Object.hasOwn(globals, marker) ? 'routed' : 'notRouted' +} + +/** + * VS Code's defaults stand for a value it does not report or that is + * malformed; `globals` is the extension host's `globalThis`. + */ export function readNetworkFacts( http: HttpSettingsSource, env: NodeJS.ProcessEnv, platform: NodeJS.Platform, muse: MuseNetworkPosture, + globals: object, ): NetworkFacts { const isVariableSet = (name: string) => (environmentValue(env, platform, name) ?? '') !== '' const proxySupport = z @@ -87,6 +113,8 @@ export function readNetworkFacts( isSystemCertificatesOn: isFlagOn(http, HTTP_POSTURE_SETTINGS.systemCertificates, true), isFetchSupportOn: isFlagOn(http, HTTP_POSTURE_SETTINGS.fetchAdditionalSupport, true), isWebSocketSupportOn: isFlagOn(http, HTTP_POSTURE_SETTINGS.webSocketAdditionalSupport, true), + fetchRouting: hostRouting(globals, 'fetch'), + webSocketRouting: hostRouting(globals, 'webSocket'), hasEnvironmentProxy: PROXY_VARIABLE_SPELLINGS.some((name) => isVariableSet(name)), hasExtraCaCertificates: isVariableSet(NODE_EXTRA_CA_CERTS_VARIABLE), museProxySource: muse.proxySource(), diff --git a/src/host/paid/paidHost.ts b/src/host/paid/paidHost.ts index cbd76e34..3e28d5f4 100644 --- a/src/host/paid/paidHost.ts +++ b/src/host/paid/paidHost.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode' import * as z from 'zod/mini' -import { PaidUseConsent, type PaidUseAnswer } from '../../core/paid/paidConsent' +import { PaidUseConsent, type PaidUseAnswer, paidUseQuestion } from '../../core/paid/paidConsent' import { PaidFeatureGate, PaidUsage, paidStateOf } from '../../core/paid/paidFeatures' import { GLOBAL_STATE_KEYS, @@ -23,8 +23,6 @@ import { modelApiPaidTier, paidFeatureName, paidFeaturePrice, - scheduledRunPrice, - subagentTaskPrice, type PaidState, type PaidUseRequest, } from '../../shared/paid' @@ -89,59 +87,6 @@ async function isTurnOnConfirmed(feature: PaidFeature): Promise { return answer === accept } -/** The popup's question and what it says about the use, in the display language. */ -function paidUseText(request: PaidUseRequest): { readonly title: string; readonly detail: string } { - switch (request.feature) { - case 'webSearch': { - return { - title: UI_TEXT.paidUseWebSearchTitle, - detail: fill(UI_TEXT.paidUseWebSearchDetail, { price: paidFeaturePrice('webSearch') }), - } - } - case 'voice': { - return { - title: UI_TEXT.paidUseVoiceTitle, - detail: fill(UI_TEXT.paidUseVoiceDetail, { price: paidFeaturePrice('voice') }), - } - } - case 'imageGeneration': { - // Every image, whatever the backend or permission mode: what is made, - // from what, and that the key pays for it (M34, M44). - const sources = request.sources.join(', ') - return { - title: fill(request.kind === 'edit' ? UI_TEXT.imageBuyEditTitle : UI_TEXT.imageBuyTitle, { - path: request.path, - }), - detail: [ - fill(UI_TEXT.imageBuyPrompt, { prompt: request.prompt }), - ...(sources === '' ? [] : [fill(UI_TEXT.imageBuySources, { paths: sources })]), - fill(UI_TEXT.imageBuyBilling, { price: paidFeaturePrice('imageGeneration') }), - ].join('\n\n'), - } - } - case 'scheduledPrompts': { - return { - title: fill(UI_TEXT.scheduleRunConfirmTitle, { model: request.modelId }), - detail: [ - fill(UI_TEXT.scheduleRunConfirmPrompt, { prompt: request.prompt }), - fill(UI_TEXT.scheduleRunConfirmPrice, { price: scheduledRunPrice(request.modelId) }), - UI_TEXT.scheduleRunConfirmExtras, - ].join('\n\n'), - } - } - case 'subagents': { - const { task } = request - return { - title: fill(UI_TEXT.paidSubagentTaskTitle, { role: task.role }), - detail: fill(UI_TEXT.paidSubagentTaskDetail, { - objective: task.objective, - price: subagentTaskPrice(task.modelId, task.attemptLimit), - }), - } - } - } -} - /** * The popup before a paid use (M58): Allow once, Allow always in this * workspace (only where it can be kept), or Deny, which is also what closing @@ -155,7 +100,7 @@ export async function askPaidUse( if (request.feature === 'subagents' && modelApiPaidTier(request.task.modelId) === undefined) { return 'deny' } - const { title, detail } = paidUseText(request) + const { title, detail } = paidUseQuestion(request) const once: vscode.MessageItem = { title: UI_TEXT.allowOnce } const always: vscode.MessageItem = { title: UI_TEXT.paidAllowAlways } const deny: vscode.MessageItem = { title: UI_TEXT.paidDeny, isCloseAffordance: true } diff --git a/src/host/views/chatSurface.ts b/src/host/views/chatSurface.ts new file mode 100644 index 00000000..38c0fb0f --- /dev/null +++ b/src/host/views/chatSurface.ts @@ -0,0 +1,40 @@ +// The handle the rest of the host talks to one chat UI through (the sidebar +// view or an editor panel). It carries no VS Code type, so the conversation +// controller and the commands that take a surface run in any host (M61, +// PLAN.md D60); webviewSetup.ts makes VS Code's. + +import type { HostToWebviewMessage, WebviewToHostMessage } from '../../shared/protocol' + +/** Messages about the conversation itself, routed to the surface's controller. */ +export type ConversationMessage = Exclude< + WebviewToHostMessage, + | { type: 'ready' } + | { type: 'inputFocusChanged' } + | { type: 'surfaceFocused' } + | { type: 'webviewError' } +> + +/** One chat UI instance (the sidebar view or one editor panel). */ +export interface ChatSurface { + readonly id: string + /** A side chat's surface (M53): opens in Plan and keeps its conversation apart. */ + readonly isSideChat?: boolean + post(message: HostToWebviewMessage): void + /** Bring the surface into view and give it keyboard focus. */ + reveal(): void + /** The conversation needs the user (turn done, approval, question): mark it when hidden (M6). */ + markUnread(): void + /** The conversation's name, shown on the tab or beside the view name (M6). */ + setTitle(title: string): void + /** Rebuild the document with a fresh nonce (the error boundary's Reload, M11). */ + reload(): void + /** + * The session a panel held before the window reloaded (D15), until it is + * live here (its history went to the webview, or another session started) + * or the user clears the conversation (M25): every `ready` until then may + * try the resume again, so a failed one is not the end of the conversation. + */ + takeRestoredSessionId(): string | undefined + /** Stop handling the surface's messages. */ + dispose(): void +} diff --git a/src/host/views/surfaceRegistry.ts b/src/host/views/surfaceRegistry.ts index 07a7b0f0..c9fd67b5 100644 --- a/src/host/views/surfaceRegistry.ts +++ b/src/host/views/surfaceRegistry.ts @@ -3,7 +3,7 @@ import type * as vscode from 'vscode' import type { HostToWebviewMessage } from '../../shared/protocol' -import type { ChatSurface } from './webviewSetup' +import type { ChatSurface } from './chatSurface' export class SurfaceRegistry { private readonly surfaces = new Map() diff --git a/src/host/views/webviewSetup.ts b/src/host/views/webviewSetup.ts index 75bb32b3..9e422a41 100644 --- a/src/host/views/webviewSetup.ts +++ b/src/host/views/webviewSetup.ts @@ -1,6 +1,6 @@ // Shared wiring for both webview surfaces (sidebar view and editor panel): // options, HTML with a fresh CSP nonce, the inbound message handler, and the -// `ChatSurface` handle the rest of the host uses to talk back. +// `ChatSurface` handle (chatSurface.ts) the rest of the host uses to talk back. import * as vscode from 'vscode' import { @@ -13,22 +13,13 @@ import { type HostToWebviewMessage, parseWebviewToHostMessage, type SettingsSnapshot, - type WebviewToHostMessage, } from '../../shared/protocol' import { buildWebviewHtml, createNonce } from '../html' import type { UiTable } from '../l10n' import type { Logger } from '../logger' +import type { ChatSurface, ConversationMessage } from './chatSurface' import { webviewErrorLog } from './webviewErrors' -/** Messages about the conversation itself, routed to the surface's controller. */ -export type ConversationMessage = Exclude< - WebviewToHostMessage, - | { type: 'ready' } - | { type: 'inputFocusChanged' } - | { type: 'surfaceFocused' } - | { type: 'webviewError' } -> - export interface WebviewHostContext { readonly extensionUri: vscode.Uri /** The display language's table, installed at activation, for every webview's HTML (D33). */ @@ -41,28 +32,6 @@ export interface WebviewHostContext { readonly onConversationMessage: (surface: ChatSurface, message: ConversationMessage) => void } -/** One chat UI instance (the sidebar view or one editor panel). */ -export interface ChatSurface extends vscode.Disposable { - readonly id: string - readonly isSideChat?: boolean - post(message: HostToWebviewMessage): void - /** Bring the surface into view and give it keyboard focus. */ - reveal(): void - /** The conversation needs the user (turn done, approval, question): mark it when hidden (M6). */ - markUnread(): void - /** The conversation's name, shown on the tab or beside the view name (M6). */ - setTitle(title: string): void - /** Rebuild the document with a fresh nonce (the error boundary's Reload, M11). */ - reload(): void - /** - * The session a panel held before the window reloaded (D15), until it is - * live here (its history went to the webview, or another session started) - * or the user clears the conversation (M25): every `ready` until then may - * try the resume again, so a failed one is not the end of the conversation. - */ - takeRestoredSessionId(): string | undefined -} - export interface SurfaceOptions { readonly id: string readonly isSideChat?: boolean diff --git a/src/host/voice/dictationHost.ts b/src/host/voice/dictationHost.ts index e14fd93f..2038cc59 100644 --- a/src/host/voice/dictationHost.ts +++ b/src/host/voice/dictationHost.ts @@ -11,8 +11,7 @@ import { env, ExtensionKind, extensions } from 'vscode' import type { CoreLogger } from '../../core/logging' import { Dictation, - type DictationHandle, - type DictationListener, + type DictationSetup, type HelperChild, type HelperInvocation, } from '../../core/voice/dictation' @@ -31,13 +30,6 @@ import { import { type RecorderProcess, recorderHelper } from '../../core/voice/recorderHelper' import { EXTENSION_QUALIFIED_ID, MUSE_VOICE_REALTIME_URL, UI_TEXT } from '../../shared/constants' -export type DictationSetup = - | { - readonly isAvailable: true - readonly create: (listener: DictationListener) => DictationHandle - } - | { readonly isAvailable: false; readonly reason: string } - const SIGNAL_EXIT = 'signal' /** The slice of a Node child process the adapter touches; `spawn` returns one. */ diff --git a/src/runtime/authCommands.ts b/src/runtime/authCommands.ts new file mode 100644 index 00000000..78ac3363 --- /dev/null +++ b/src/runtime/authCommands.ts @@ -0,0 +1,121 @@ +// The sign-in commands an editor's terminal sign-in runs (PLAN.md D61, +// D62): `auth set|status|clear` for the Model API key in the OS credential +// store, and `login` for Muse Code's own sign-in. Each returns the process +// exit code. Nothing here prints the key or any part of it. + +import type { LaunchResolution } from '../core/backends/musecode/launch' +import { isValidModelApiKey, type SecretStore } from '../host/auth/credentialStore' +import { MUSE_LOGIN_ARGS, SECRET_KEYS, UI_TEXT } from '../shared/constants' +import { fill } from '../shared/l10n/text' + +export interface AuthCommandDeps { + readonly secrets: SecretStore + /** Where the key lives, as the user knows it (`credentialStoreName`). */ + readonly storeName: string + /** One line from the user, not echoed when it comes from a terminal. */ + readonly readSecret: (prompt: string) => Promise + readonly print: (line: string) => void + readonly printError: (line: string) => void +} + +const EXIT_OK = 0 +const EXIT_FAILED = 1 + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function unavailable(deps: AuthCommandDeps, error: unknown): number { + deps.printError(fill(UI_TEXT.acpStoreUnavailable, { reason: describe(error) })) + return EXIT_FAILED +} + +export async function authSet(deps: AuthCommandDeps): Promise { + const typed = await deps.readSecret(UI_TEXT.acpKeyPrompt) + const candidate = typed.trim() + if (candidate === '') { + deps.printError(UI_TEXT.acpKeyNotStored) + return EXIT_FAILED + } + if (!isValidModelApiKey(candidate)) { + deps.printError(UI_TEXT.apiKeyInvalid) + return EXIT_FAILED + } + try { + await deps.secrets.store(SECRET_KEYS.modelApiKey, candidate) + } catch (error: unknown) { + return unavailable(deps, error) + } + deps.print(fill(UI_TEXT.acpKeyStored, { store: deps.storeName })) + return EXIT_OK +} + +export async function authStatus(deps: AuthCommandDeps): Promise { + let stored: string | undefined + try { + stored = await deps.secrets.get(SECRET_KEYS.modelApiKey) + } catch (error: unknown) { + return unavailable(deps, error) + } + if (stored === undefined || stored === '') { + deps.print(UI_TEXT.acpKeyAbsent) + return EXIT_FAILED + } + deps.print(fill(UI_TEXT.acpKeyPresent, { store: deps.storeName })) + return EXIT_OK +} + +export async function authClear(deps: AuthCommandDeps): Promise { + try { + await deps.secrets.delete(SECRET_KEYS.modelApiKey) + } catch (error: unknown) { + return unavailable(deps, error) + } + deps.print(UI_TEXT.acpKeyCleared) + return EXIT_OK +} + +/** The two events of a child process `login` waits for. */ +export interface ExitingProcess { + once(event: 'error', listener: (error: Error) => void): unknown + once(event: 'exit', listener: (code: number | null) => void): unknown +} + +export interface LoginDeps { + readonly resolveLaunch: () => LaunchResolution + readonly environment: () => NodeJS.ProcessEnv + /** `child_process.spawn` with the terminal handed over (`stdio: 'inherit'`). */ + readonly spawnInTerminal: ( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + ) => ExitingProcess + readonly printError: (line: string) => void +} + +/** `muse login` in this terminal, run the way the agent runs `muse serve` (same CLI, same environment). */ +export function login(deps: LoginDeps): Promise { + const resolution = deps.resolveLaunch() + if (!resolution.ok) { + deps.printError( + `${resolution.reason} ${fill(UI_TEXT.cliSearched, { paths: resolution.searched.join(', ') })}`, + ) + return Promise.resolve(EXIT_FAILED) + } + const { launch } = resolution + const prefix = launch.args.slice(0, launch.args.length - launch.serveArgs.length) + const child = deps.spawnInTerminal( + launch.command, + [...prefix, ...MUSE_LOGIN_ARGS], + deps.environment(), + ) + return new Promise((resolve) => { + child.once('error', (error) => { + deps.printError(describe(error)) + resolve(EXIT_FAILED) + }) + child.once('exit', (code) => { + resolve(code ?? EXIT_FAILED) + }) + }) +} diff --git a/src/runtime/backends.ts b/src/runtime/backends.ts new file mode 100644 index 00000000..e74fdd4e --- /dev/null +++ b/src/runtime/backends.ts @@ -0,0 +1,364 @@ +// The ACP agent's backend (PLAN.md D62): the panel's backend managers, +// given in this process what VS Code gives them in the extension, one per +// workspace folder. Muse Code signs in on its own and the subscription +// pays, its sign-in read as the panel reads it (D26: the credential file's +// structure, and `account/read` where only the CLI can say); the Model API +// backend reads the key from the OS credential store +// (D61) and is the extension's own bundle, dist/modelApi.js beside acp.js, +// loaded the first time that backend starts (M57, PLAN.md D6). Its paid +// features are the agent's flags, each use asked in the editor (M58, D48), +// and nothing here sees an editor's unsaved buffers until file access goes +// through the client (M63c). + +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import type { AcpBackend, BackendReadiness } from '../acp/agent' +import { AcpPaidUse } from '../acp/paid' +import type { AgentHost } from '../core/agent/agentBackend' +import type { CliSignIn } from '../core/backends/musecode/credentialFile' +import { environmentValue } from '../core/backends/musecode/launch' +import { personalSkillsRoot } from '../core/context/skills' +import { memoryDataRoot } from '../core/memory/memoryLocation' +import { MemoryStore } from '../core/memory/memoryStore' +import { fileContextIo } from '../host/backend/contextIo' +import { describeEnvironment } from '../host/backend/environment' +import { createFileSessionStore } from '../host/backend/fileSessionStore' +import { jobSourceReader } from '../host/backend/jobSource' +import { createMemoryIo, systemPath } from '../host/backend/memoryIo' +import { ModelApiBackendManager } from '../host/backend/modelApiBackendManager' +import { MuseCodeBackendManager, type ProxySettings } from '../host/backend/museCodeBackendManager' +import { shellJobAssembly } from '../host/backend/shellJob' +import { createToolIo } from '../host/backend/toolIo' +import { AccountHosts, connectAccountSession } from '../host/auth/accountHost' +import { CliAccount, isCliSignedIn } from '../host/auth/cliAccount' +import { CredentialStore, type SecretStore } from '../host/auth/credentialStore' +import type { Logger } from '../host/logger' +import { createWorkspaceFileLister } from '../host/mention/workspaceFiles' +import { + type EnvironmentVariable, + MENTION_INDEX_LIMIT, + MODEL_API_BUNDLE_FILE, + SEARCH_WORKER_FILE, + SECRET_KEYS, + SETTING_DEFAULTS, + UI_TEXT, +} from '../shared/constants' +import { fill } from '../shared/l10n/text' +import type { ServeOptions } from './cliArgs' +import { + agentDataFolder, + type DataFolderInput, + paidGrantsFile, + workspaceSessionsFolder, +} from './dataFolder' +import { withoutCredentials } from './credentialVariables' +import { walkFiles } from './fileWalk' +import { paidGrantFile } from './paidGrants' + +export interface RuntimeBackendDeps { + readonly options: ServeOptions + readonly version: string + /** The folder holding `acp.js`, `modelApi.js` and `searchWorker.js`. */ + readonly distDir: string + readonly platform: NodeJS.Platform + readonly env: NodeJS.ProcessEnv + readonly homeDir: string + readonly secrets: SecretStore + readonly runGit: (args: readonly string[], cwd: string) => Promise + /** + * The credential variables taken out of the agent's own environment at + * start (credentialVariables.ts): handed back to Muse Code's processes + * only, as the extension's `muse serve` inherits them (D1). + */ + readonly museCodeCredentials: readonly EnvironmentVariable[] + /** The Model API's transport. */ + readonly fetch: typeof fetch + /** Waits between retries and rename attempts; injectable so tests do not sleep. */ + readonly sleep: (ms: number) => Promise + readonly log: Logger +} + +export interface RuntimeBackend { + readonly backend: AcpBackend + /** Muse Code's launch and environment, for `login`. */ + readonly museCode: MuseCodeBackendManager + /** The flagged paid features and their questions, shared with the agent (M63c, M58). */ + readonly paid: AcpPaidUse + /** + * At start: "always" lapses for a paid feature the Model API agent was + * started without. A Muse Code agent has no paid flags, so it leaves the + * grants to the Model API agent, which a user may run beside it. + */ + readonly forgetUnflaggedGrants: () => Promise + readonly close: () => Promise +} + +// No editor proxy setting exists outside VS Code; the CLI reads the +// environment's HTTPS_PROXY and NO_PROXY as it inherits them. +const NO_EDITOR_PROXY: ProxySettings = { proxy: '', noProxy: [] } + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function museCodeManager(deps: RuntimeBackendDeps, workspaceRoot: string | undefined) { + const { options, log } = deps + return new MuseCodeBackendManager({ + log, + extensionVersion: deps.version, + getConfiguredBinaryPath: () => options.museBinary, + getEnvironmentVariables: () => deps.museCodeCredentials, + workspaceRoot, + getShellSandbox: () => options.shellSandbox, + // No `--sandbox-network` (M56): Muse Code's default, or a managed policy's. + getSandboxNetwork: () => SETTING_DEFAULTS.sandboxNetwork, + userProfileDir: deps.env['USERPROFILE'], + isWorkspaceTrusted: () => options.trustWorkspace, + getProxySettings: () => NO_EDITOR_PROXY, + }) +} + +/** Muse Code's config and data homes as `muse serve` sees them: its skills and its memory. */ +interface MuseHomes { + readonly xdgConfigHome: string | undefined + readonly xdgDataHome: string | undefined +} + +function modelApiManager( + deps: RuntimeBackendDeps, + credentials: CredentialStore, + workspaceRoot: string, + homes: MuseHomes, + paid: AcpPaidUse, +): ModelApiBackendManager { + const { options, log, platform } = deps + const isWorkspaceTrusted = () => options.trustWorkspace + const dataInput: DataFolderInput = { platform, env: deps.env, homeDir: deps.homeDir } + const systemRoot = deps.env['SystemRoot'] + const warn = (message: string) => { + log.warn(message) + } + const listFiles = createWorkspaceFileLister({ + workspaceRoot, + respectGitIgnore: () => SETTING_DEFAULTS.respectGitIgnore, + isWorkspaceTrusted, + runGit: deps.runGit, + findFiles: () => walkFiles(workspaceRoot, MENTION_INDEX_LIMIT, log), + log, + }) + const io = createToolIo({ + platform, + listFiles, + systemRoot, + // No credential variable reaches a tool's process (AGENTS.md rule 8). + env: () => withoutCredentials(deps.env), + searchWorkerPath: path.join(deps.distDir, SEARCH_WORKER_FILE), + log: warn, + // The agent cannot see the editor's buffers (D62); the client's `fs/*` will (M63c). + hasUnsavedChanges: () => false, + shellJobAssembly: + platform === 'win32' && systemRoot !== undefined + ? shellJobAssembly({ + storageDir: agentDataFolder(dataInput), + systemRoot, + // The job's C#, shipped in the package beside `dist/` (M56, PLAN.md D6). + readJobSource: jobSourceReader(path.dirname(deps.distDir)), + log: warn, + }) + : undefined, + }) + // Muse Code's memory (M49, PLAN.md D41), in the data home `muse serve` sees. + const warnMemory = (message: string) => { + warn(`Memory: ${message}`) + } + const memory = new MemoryStore({ + io: createMemoryIo(io, { warn: warnMemory }), + platform, + dataRoot: () => + memoryDataRoot({ platform, homeDir: deps.homeDir, xdgDataHome: homes.xdgDataHome }), + workspaceRoot, + systemPath, + warn: warnMemory, + }) + return new ModelApiBackendManager({ + log, + getApiKey: () => credentials.getApiKey(), + workspaceRoot, + io, + contextIo: fileContextIo, + fetch: deps.fetch, + newId: () => randomUUID(), + now: () => Date.now(), + sleep: deps.sleep, + random: () => Math.random(), + personalSkillsRoot: personalSkillsRoot({ + platform, + homeDir: deps.homeDir, + xdgConfigHome: homes.xdgConfigHome, + }), + isWorkspaceTrusted, + store: createFileSessionStore({ + directory: workspaceSessionsFolder(dataInput, workspaceRoot), + log, + retentionDays: () => SETTING_DEFAULTS.cleanupPeriodDays, + now: () => Date.now(), + sleep: deps.sleep, + }), + describeEnvironment: () => + describeEnvironment({ + runGit: deps.runGit, + workspaceRoot, + isWorkspaceTrusted, + log, + now: () => Date.now(), + }), + isPaidFeatureOn: (feature) => paid.isOn(feature), + notePaidUse: (feature, units) => { + paid.noteUse(feature, units) + }, + // The panel's default (M56); the agent has no setting for the longer retention. + promptCacheRetention: () => SETTING_DEFAULTS.modelApiPromptCacheRetention, + // Each use asked in the editor's session (M58, PLAN.md D48). Child tasks + // are paid (M48, D45) and the agent's paid features are its two flags + // (D62), so `subagents` is never on here and every task is denied. + allowsPaidUse: (request, requiresAsking, sessionId) => + paid.allows(workspaceRoot, sessionId, request, requiresAsking), + isPaidUseRemembered: (feature) => paid.isRemembered(workspaceRoot, feature), + noteSubagentUsage: (modelId) => { + log.warn(`A subagent's usage on ${modelId} was reported, but the agent runs no subagents`) + }, + memory, + bundlePath: path.join(deps.distDir, MODEL_API_BUNDLE_FILE), + // VS Code's settings do not reach the agent: a failed request names its + // environment variables instead (PLAN.md D62, Q66), and a missing bundle + // the agent's package, not the extension. + networkAdvice: 'agent', + bundleUnavailable: () => UI_TEXT.acpModelApiBundleUnavailable, + }) +} + +/** The backend `--backend` names, its managers created per folder on first use. */ +export function createRuntimeBackend(deps: RuntimeBackendDeps): RuntimeBackend { + const museCode = museCodeManager(deps, undefined) + const museCodeHosts = new Map() + const modelApiHosts = new Map() + const credentials = new CredentialStore( + deps.secrets, + (message) => { + deps.log.warn(message) + }, + "the operating system's credential store", + ) + const paid = new AcpPaidUse({ + flagged: deps.options.paidFeatures, + canRemember: () => deps.options.trustWorkspace, + grants: paidGrantFile({ + file: paidGrantsFile({ platform: deps.platform, env: deps.env, homeDir: deps.homeDir }), + log: deps.log, + sleep: deps.sleep, + }), + log: deps.log, + }) + const museEnvironment = museCode.childEnvironment() + const homes: MuseHomes = { + xdgConfigHome: environmentValue(museEnvironment, deps.platform, 'XDG_CONFIG_HOME'), + xdgDataHome: environmentValue(museEnvironment, deps.platform, 'XDG_DATA_HOME'), + } + + // Muse Code's account methods on a short-lived host of their own, as the + // panel asks them (PR #49): `account/read` when the file cannot say. + const accountHosts = new AccountHosts( + (signal) => connectAccountSession(museCode, deps.version, deps.log, undefined, signal), + deps.log, + ) + const cliAccount = new CliAccount({ + platform: deps.platform, + credentialFilePath: () => museCode.credentialFilePath(), + probe: () => accountHosts.probe(), + log: deps.log, + }) + + /** + * Muse Code's readiness, as the panel's sign-in gate counts it (D26): + * `META_API_KEY` in the CLI's environment (with it `muse serve` starts + * whatever the file says), or the CLI's own sign-in from the file's + * structure, asked of the CLI where only it can say. `authenticate`, the + * user saying they signed in, is the agent's user action, as the panel's + * Check again is: it forgets what the CLI said before and may ask on + * macOS, where a question can read the Keychain. A session or a list is + * not: on macOS it takes the file's word or the panel's passive estimate + * (a turn's `authRequired` corrects it) rather than start `muse serve` + * each time. A macOS file on Windows or Linux stops `muse serve`, so it is + * said as such. + */ + const museCodeReadiness = async (isRecheck: boolean): Promise => { + const resolution = museCode.resolveLaunch() + if (!resolution.ok) { + return { + state: 'unavailable', + message: `${resolution.reason} ${fill(UI_TEXT.cliSearched, { paths: resolution.searched.join(', ') })}`, + } + } + if (museCode.hasEnvironmentKey()) { + return { state: 'ready' } + } + if (isRecheck) { + cliAccount.forgetAnswers() + } + const signIn: CliSignIn = await cliAccount.signIn(isRecheck) + if (signIn === 'unsupportedHere') { + return { + state: 'unavailable', + message: fill(UI_TEXT.cliCredentialUnsupported, { path: museCode.credentialFilePath() }), + } + } + return isCliSignedIn(signIn) + ? { state: 'ready' } + : { state: 'signedOut', message: UI_TEXT.acpMuseCodeSignedOut } + } + + const modelApiReadiness = async (): Promise => { + let key: string | undefined + try { + key = await deps.secrets.get(SECRET_KEYS.modelApiKey) + } catch (error: unknown) { + return { + state: 'unavailable', + message: fill(UI_TEXT.acpStoreUnavailable, { reason: describe(error) }), + } + } + return key === undefined || key === '' + ? { state: 'signedOut', message: UI_TEXT.acpNoStoredKey } + : { state: 'ready' } + } + + const hostFor = (cwd: string): Promise => { + if (deps.options.backend === 'modelApi') { + const manager = modelApiHosts.get(cwd) ?? modelApiManager(deps, credentials, cwd, homes, paid) + modelApiHosts.set(cwd, manager) + return manager.ensureHost() + } + const manager = museCodeHosts.get(cwd) ?? museCodeManager(deps, cwd) + museCodeHosts.set(cwd, manager) + return manager.ensureHost() + } + + return { + backend: { + kind: deps.options.backend, + readiness: (isRecheck) => + deps.options.backend === 'modelApi' ? modelApiReadiness() : museCodeReadiness(isRecheck), + hostFor, + }, + museCode, + paid, + forgetUnflaggedGrants: () => + deps.options.backend === 'modelApi' ? paid.forgetUnflagged() : Promise.resolve(), + close: async () => { + // A probe still waiting on its short-lived host ends with the agent. + accountHosts.close() + const managers = [...museCodeHosts.values(), ...modelApiHosts.values()] + await Promise.all(managers.map((manager) => manager.dispose())) + }, + } +} diff --git a/src/runtime/cliArgs.ts b/src/runtime/cliArgs.ts new file mode 100644 index 00000000..f2274090 --- /dev/null +++ b/src/runtime/cliArgs.ts @@ -0,0 +1,143 @@ +// The agent's command line (PLAN.md D62): serve ACP on stdio, or one of the +// sign-in commands the editors' terminal sign-ins run (D61). Pure: the +// arguments in, what to do out, with the reason when they make no sense. + +import { parseArgs } from 'node:util' +import { + ACP_BACKENDS, + ACP_DEFAULT_BACKEND, + ACP_PAID_FEATURES, + ACP_PAID_FLAGS, + type AcpBackendKind, + type AcpPaidFeature, + SETTING_DEFAULTS, + SHELL_SANDBOX_MODES, + type ShellSandboxMode, + UI_TEXT, +} from '../shared/constants' +import { fill } from '../shared/l10n/text' + +export interface ServeOptions { + /** Which account pays; chosen here, never guessed (D62). */ + readonly backend: AcpBackendKind + /** A folder's rules, skills and memory load (D13's flag, as Muse Code takes it). */ + readonly trustWorkspace: boolean + /** The Muse Code CLI to run; empty to find it where the panel looks. */ + readonly museBinary: string + readonly shellSandbox: ShellSandboxMode + readonly canBypass: boolean + readonly allowsContributorModels: boolean + /** The paid features whose flags were given (M63c); each use asks first (M58). Model API only. */ + readonly paidFeatures: readonly AcpPaidFeature[] + /** The finest log detail on stderr. */ + readonly isVerbose: boolean +} + +export type RuntimeCommand = + | { readonly command: 'serve'; readonly options: ServeOptions } + | { readonly command: 'login'; readonly options: ServeOptions } + | { readonly command: 'authSet' | 'authStatus' | 'authClear' | 'help' | 'version' } + | { readonly command: 'invalid'; readonly reason: string } + +/** `auth set|status|clear`: the key's three commands (D61). */ +function authCommand(name: string | undefined): 'authSet' | 'authStatus' | 'authClear' | undefined { + switch (name) { + case 'set': { + return 'authSet' + } + case 'status': { + return 'authStatus' + } + case 'clear': { + return 'authClear' + } + default: { + return undefined + } + } +} + +function isOneOf(allowed: readonly T[], value: string | undefined): value is T { + return value !== undefined && (allowed as readonly string[]).includes(value) +} + +function invalid(argument: string): RuntimeCommand { + return { command: 'invalid', reason: fill(UI_TEXT.acpUnknownArgument, { argument }) } +} + +/** The paid features whose flags were given, in the flags' order. */ +function paidFeaturesOf(values: Readonly>): AcpPaidFeature[] { + return ACP_PAID_FEATURES.filter((feature) => values[ACP_PAID_FLAGS[feature]] === true) +} + +export function parseCommandLine(argv: readonly string[]): RuntimeCommand { + let parsed: ReturnType + try { + parsed = parseCommandLineStrictly(argv) + } catch (error: unknown) { + return { command: 'invalid', reason: error instanceof Error ? error.message : String(error) } + } + const { values, positionals } = parsed + if (values.help === true) { + return { command: 'help' } + } + if (values.version === true) { + return { command: 'version' } + } + const backend = values.backend ?? ACP_DEFAULT_BACKEND + if (!isOneOf(ACP_BACKENDS, backend)) { + return invalid(`--backend ${backend}`) + } + const shellSandbox = values['shell-sandbox'] ?? SETTING_DEFAULTS.shellSandbox + if (!isOneOf(SHELL_SANDBOX_MODES, shellSandbox)) { + return invalid(`--shell-sandbox ${shellSandbox}`) + } + const paidFeatures = paidFeaturesOf(values) + const [firstPaid] = paidFeatures + if (firstPaid !== undefined && backend !== 'modelApi') { + return { + command: 'invalid', + reason: fill(UI_TEXT.acpPaidNeedsModelApi, { argument: `--${ACP_PAID_FLAGS[firstPaid]}` }), + } + } + const options: ServeOptions = { + backend, + trustWorkspace: values['trust-workspace'] === true, + museBinary: values['muse-binary'] ?? SETTING_DEFAULTS.museBinaryPath, + shellSandbox, + canBypass: values['allow-dangerously-skip-permissions'] === true, + allowsContributorModels: values['allow-contributor-models'] === true, + paidFeatures, + isVerbose: values.verbose === true, + } + const [first, second, ...rest] = positionals + if (first === undefined) { + return { command: 'serve', options } + } + if (first === 'login' && second === undefined) { + return { command: 'login', options } + } + const auth = first === 'auth' && rest.length === 0 ? authCommand(second) : undefined + return auth === undefined ? invalid(positionals.join(' ')) : { command: auth } +} + +function parseCommandLineStrictly(argv: readonly string[]) { + return parseArgs({ + args: [...argv], + allowPositionals: true, + strict: true, + options: { + backend: { type: 'string' }, + 'trust-workspace': { type: 'boolean' }, + 'muse-binary': { type: 'string' }, + 'shell-sandbox': { type: 'string' }, + 'allow-dangerously-skip-permissions': { type: 'boolean' }, + 'allow-contributor-models': { type: 'boolean' }, + [ACP_PAID_FLAGS.webSearch]: { type: 'boolean' }, + [ACP_PAID_FLAGS.imageGeneration]: { type: 'boolean' }, + verbose: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + }, + }) +} diff --git a/src/runtime/credentialVariables.ts b/src/runtime/credentialVariables.ts new file mode 100644 index 00000000..e170a76b --- /dev/null +++ b/src/runtime/credentialVariables.ts @@ -0,0 +1,37 @@ +// Credential variables and the processes the agent starts (AGENTS.md rule 8, +// PLAN.md D1, D61). The agent never reads the Model API key from its +// environment. What a user sets there for Muse Code itself (`META_API_KEY`, +// which the CLI prefers over its sign-in, as Meta documents) reaches Muse +// Code as it does from the extension, whose `muse serve` inherits the user's +// environment (D1's amendment), and counts as its credential. Nothing else +// the agent starts (a shell command, a hook, git, the Windows job helpers) +// sees it or any other credential variable: at start the agent takes them +// out of its own environment and hands them back only to Muse Code's +// processes (`muse serve`, its account hosts, `muse login`), the way the +// extension adds `museSpark.environmentVariables` to them. + +import { isCredentialVariable } from '../host/backend/toolIo' +import type { EnvironmentVariable } from '../shared/constants' + +/** The credential variables in `env`, by name and value. */ +function credentialsIn(env: NodeJS.ProcessEnv): EnvironmentVariable[] { + return Object.entries(env).flatMap(([name, value]) => + value !== undefined && isCredentialVariable(name) ? [{ name, value }] : [], + ) +} + +/** Takes every credential variable out of `env` (the agent's own, at start) and returns them. */ +export function takeCredentials(env: NodeJS.ProcessEnv): readonly EnvironmentVariable[] { + const taken = credentialsIn(env) + for (const { name } of taken) { + Reflect.deleteProperty(env, name) + } + return taken +} + +/** `env` without any credential variable: what a tool process gets. */ +export function withoutCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const copy = { ...env } + takeCredentials(copy) + return copy +} diff --git a/src/runtime/dataFolder.ts b/src/runtime/dataFolder.ts new file mode 100644 index 00000000..190689fc --- /dev/null +++ b/src/runtime/dataFolder.ts @@ -0,0 +1,61 @@ +// Where the ACP agent keeps what the panel keeps in VS Code's storage +// (PLAN.md D62): the user's data folder per platform, then one folder per +// workspace, named by a hash of its path so no path lands in a file name. + +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + ACP_DATA_FOLDER, + ACP_PAID_GRANTS_FILE, + ACP_SESSIONS_SUBFOLDER, + ACP_WORKSPACE_HASH_CHARS, + MODEL_API_SESSIONS_DIR, +} from '../shared/constants' + +export interface DataFolderInput { + readonly platform: NodeJS.Platform + readonly env: NodeJS.ProcessEnv + readonly homeDir: string +} + +function pathModuleFor(input: DataFolderInput): path.PlatformPath { + return input.platform === 'win32' ? path.win32 : path.posix +} + +/** `%LOCALAPPDATA%\Muse Spark Code`, `~/Library/Application Support/Muse Spark Code`, `$XDG_DATA_HOME/muse-spark-code`. */ +export function agentDataFolder(input: DataFolderInput): string { + const { platform, env, homeDir } = input + if (platform === 'win32') { + const localAppData = env['LOCALAPPDATA'] ?? path.win32.join(homeDir, 'AppData', 'Local') + return path.win32.join(localAppData, ACP_DATA_FOLDER.win32) + } + if (platform === 'darwin') { + return path.posix.join(homeDir, 'Library', 'Application Support', ACP_DATA_FOLDER.darwin) + } + const dataHome = env['XDG_DATA_HOME'] ?? path.posix.join(homeDir, '.local', 'share') + return path.posix.join(dataHome, ACP_DATA_FOLDER.other) +} + +/** A workspace's name in the data folder: the start of its path's SHA-256. */ +export function workspaceKey(workspaceRoot: string): string { + return createHash('sha256').update(workspaceRoot).digest('hex').slice(0, ACP_WORKSPACE_HASH_CHARS) +} + +/** The Model API sessions of one workspace. */ +export function workspaceSessionsFolder(input: DataFolderInput, workspaceRoot: string): string { + return pathModuleFor(input).join( + agentDataFolder(input), + ACP_SESSIONS_SUBFOLDER, + workspaceKey(workspaceRoot), + MODEL_API_SESSIONS_DIR, + ) +} + +/** Every workspace's "Allow always" for paid uses (M58, PLAN.md D48). */ +export function paidGrantsFile(input: DataFolderInput): string { + return pathModuleFor(input).join( + agentDataFolder(input), + ACP_SESSIONS_SUBFOLDER, + ACP_PAID_GRANTS_FILE, + ) +} diff --git a/src/runtime/fileWalk.ts b/src/runtime/fileWalk.ts new file mode 100644 index 00000000..66e4772e --- /dev/null +++ b/src/runtime/fileWalk.ts @@ -0,0 +1,44 @@ +// A folder's files when git cannot list them (not a repository, no git, or +// an untrusted folder, where git would read the repository's own config): +// the walk that stands in for VS Code's file search in the ACP agent +// (PLAN.md D62). Breadth first, relative paths with forward slashes, never +// into `.git` or `node_modules`, never through a link (D24), at most `limit`. + +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import type { Logger } from '../host/logger' +import { FILE_WALK_SKIPPED } from '../shared/constants' + +export async function walkFiles( + root: string, + limit: number, + log: Logger, +): Promise { + const found: string[] = [] + const folders = [''] + while (found.length < limit) { + const folder = folders.shift() + if (folder === undefined) { + break + } + let entries + try { + entries = await readdir(path.join(root, folder), { withFileTypes: true }) + } catch (error: unknown) { + log.warn(`Could not list ${folder === '' ? root : folder}: ${String(error)}`) + continue + } + for (const entry of entries) { + const relative = folder === '' ? entry.name : `${folder}/${entry.name}` + if (FILE_WALK_SKIPPED.has(entry.name)) { + continue + } + if (entry.isDirectory()) { + folders.push(relative) + } else if (entry.isFile()) { + found.push(relative) + } + } + } + return found.slice(0, limit) +} diff --git a/src/runtime/hiddenInput.ts b/src/runtime/hiddenInput.ts new file mode 100644 index 00000000..fdbe536e --- /dev/null +++ b/src/runtime/hiddenInput.ts @@ -0,0 +1,79 @@ +// One line of secret input for `auth set` (PLAN.md D61). From a terminal it +// is read with echo off, a character at a time, Backspace honoured and +// Ctrl+C refused; from a pipe it is the first line. Either way the value +// goes nowhere but the caller. + +import type { Readable, Writable } from 'node:stream' + +/** The slice of `process.stdin` the reader uses; a TTY stream has `setRawMode`. */ +export interface InputStream extends Readable { + readonly isTTY?: boolean + setRawMode?(isRaw: boolean): unknown +} + +const ENTER = new Set(['\r', '\n']) +const BACKSPACE = new Set(['\u{7F}', '\b']) +const INTERRUPT = '\u{3}' +const END_OF_INPUT = '\u{4}' +const LINE_BREAK = /\r?\n/ + +function readPipedLine(input: Readable): Promise { + return new Promise((resolve, reject) => { + let text = '' + input.setEncoding('utf8') + input.on('data', (chunk: string) => { + text += chunk + }) + input.once('end', () => { + resolve(text.split(LINE_BREAK, 1)[0] ?? '') + }) + input.once('error', reject) + }) +} + +function readTypedLine(input: InputStream, output: Writable): Promise { + return new Promise((resolve, reject) => { + let typed = '' + const finish = (error?: Error) => { + input.setRawMode?.(false) + input.pause() + input.removeListener('data', onData) + output.write('\n') + if (error === undefined) { + resolve(typed) + } else { + reject(error) + } + } + const onData = (chunk: string) => { + for (const character of chunk) { + if (character === END_OF_INPUT || ENTER.has(character)) { + finish() + return + } + if (character === INTERRUPT) { + finish(new Error('Cancelled')) + return + } + typed = BACKSPACE.has(character) ? typed.slice(0, -1) : `${typed}${character}` + } + } + input.setEncoding('utf8') + input.setRawMode?.(true) + input.on('data', onData) + input.resume() + }) +} + +/** Writes `prompt` to `output`, then reads the line without showing it. */ +export async function readSecretLine( + prompt: string, + input: InputStream, + output: Writable, +): Promise { + if (input.isTTY !== true) { + return await readPipedLine(input) + } + output.write(prompt) + return await readTypedLine(input, output) +} diff --git a/src/runtime/keyStore.ts b/src/runtime/keyStore.ts new file mode 100644 index 00000000..2d714a67 --- /dev/null +++ b/src/runtime/keyStore.ts @@ -0,0 +1,50 @@ +// The Model API key outside VS Code (PLAN.md D61): the operating system's +// credential store, reached in this process. Windows Credential Manager, +// the macOS login Keychain, and on Linux the Secret Service only (the +// kernel keyring would forget the key at reboot, so it is never used). +// The entry is opened per call through an injected factory, so the tests +// run against a map and the process against `@napi-rs/keyring`. A missing +// entry reads as null from the native binding, whatever its typings say +// (found against GNOME Keyring, docs/certification/m63.md), and as +// undefined everywhere past this file. + +import type { SecretStore } from '../host/auth/credentialStore' +import { KEYRING_SERVICE, UI_TEXT } from '../shared/constants' + +/** The three calls the agent makes on one credential (`@napi-rs/keyring`'s `AsyncEntry`). */ +export interface KeyringEntry { + getPassword(): Promise + setPassword(password: string): Promise + deletePassword(): Promise +} + +/** Opens the entry for a service and account; throws when the store cannot be used. */ +export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry + +/** The credential store as the key's `SecretStore`, each call on a freshly opened entry. */ +export function keyringSecretStore(openEntry: KeyringEntryFactory): SecretStore { + return { + get: async (key) => (await openEntry(KEYRING_SERVICE, key).getPassword()) ?? undefined, + store: async (key, value) => { + await openEntry(KEYRING_SERVICE, key).setPassword(value) + }, + delete: async (key) => { + await openEntry(KEYRING_SERVICE, key).deletePassword() + }, + } +} + +/** Where the key lives on this platform, as the user knows it. */ +export function credentialStoreName(platform: NodeJS.Platform): string { + switch (platform) { + case 'win32': { + return UI_TEXT.acpStoreNames.windows + } + case 'darwin': { + return UI_TEXT.acpStoreNames.macos + } + default: { + return UI_TEXT.acpStoreNames.linux + } + } +} diff --git a/src/runtime/locale.ts b/src/runtime/locale.ts new file mode 100644 index 00000000..3fc45059 --- /dev/null +++ b/src/runtime/locale.ts @@ -0,0 +1,18 @@ +// The language the agent speaks (PLAN.md D33, D62): the terminal's locale +// (`LC_ALL`, `LC_MESSAGES`, `LANG`, as POSIX orders them), else the one the +// runtime reports (Windows sets none of the variables). `de_DE.UTF-8` +// becomes `de-de`, which the table lookup reads as German. + +const POSIX_DEFAULT_LOCALES: ReadonlySet = new Set(['', 'C', 'POSIX']) +const LOCALE_VARIABLES = ['LC_ALL', 'LC_MESSAGES', 'LANG'] as const + +export function displayLanguage(env: NodeJS.ProcessEnv, runtimeLocale: string): string { + const posix = LOCALE_VARIABLES.map((name) => env[name]).find( + (value) => value !== undefined && value !== '', + ) + const tag = + posix === undefined || POSIX_DEFAULT_LOCALES.has(posix.split('.', 1)[0] ?? '') + ? runtimeLocale + : (posix.split(/[.@]/, 1)[0] ?? posix).replaceAll('_', '-') + return tag.toLowerCase() +} diff --git a/src/runtime/main.ts b/src/runtime/main.ts new file mode 100644 index 00000000..064475ba --- /dev/null +++ b/src/runtime/main.ts @@ -0,0 +1,230 @@ +// `muse-spark-code-acp` (PLAN.md D62): the Muse Spark agent for editors that +// speak the Agent Client Protocol, and the sign-in commands their terminal +// sign-ins run (D61). stdout carries the protocol; everything the user or +// the log reads goes to stderr, except the sign-in commands' own output. +// Exercised through the built `dist/acp.js` by the stdio e2e test. + +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { Writable } from 'node:stream' +import { ndJsonStream } from '@agentclientprotocol/sdk' +import { AsyncEntry } from '@napi-rs/keyring' +import { createAcpAgent, type SignInMethod } from '../acp/agent' +import { processGitRunner } from '../host/git' +import { loadUiTable } from '../host/l10n' +import { ACP_AGENT_NAME, ACP_AUTH_METHODS, SETTING_DEFAULTS, UI_TEXT } from '../shared/constants' +import { fill } from '../shared/l10n/text' +import { authClear, type AuthCommandDeps, authSet, authStatus, login } from './authCommands' +import { createRuntimeBackend } from './backends' +import { parseCommandLine, type ServeOptions } from './cliArgs' +import { readSecretLine } from './hiddenInput' +import { credentialStoreName, keyringSecretStore } from './keyStore' +import { takeCredentials } from './credentialVariables' +import { displayLanguage } from './locale' +import { envProxyWarning } from './proxyWarning' +import type { Logger } from '../host/logger' +import { type LogLevel, stderrLogger } from './stderrLog' +import { webReadable } from './webStreams' + +const EXIT_FAILED = 1 +// Credential variables leave the agent's own environment before anything +// starts a process; only Muse Code's processes get them back (rule 8). +const museCodeCredentials = takeCredentials(process.env) +// The package root holds `package.json` and `l10n/`; this file runs from `dist/`. +const distDir = __dirname +const packageRoot = path.dirname(distDir) + +function writeLine(stream: NodeJS.WriteStream, line: string): void { + stream.write(`${line}\n`) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function packageVersion(): string { + const manifest: unknown = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8')) + const version = + typeof manifest === 'object' && manifest !== null && 'version' in manifest + ? manifest.version + : undefined + if (typeof version !== 'string') { + throw new TypeError(`${packageRoot}/package.json has no version`) + } + return version +} + +/** The OS credential store's entry; Linux is held to the Secret Service (D61). */ +const secrets = keyringSecretStore( + (service, account) => new AsyncEntry(service, account, { linux: { store: 'secret-service' } }), +) + +function authDeps(): AuthCommandDeps { + return { + secrets, + storeName: credentialStoreName(process.platform), + readSecret: (prompt) => readSecretLine(prompt, process.stdin, process.stderr), + print: (line) => { + writeLine(process.stdout, line) + }, + printError: (line) => { + writeLine(process.stderr, line) + }, + } +} + +function signInMethod(options: ServeOptions): SignInMethod { + if (options.backend === 'modelApi') { + const { id, args } = ACP_AUTH_METHODS.modelApiKey + return { + id, + name: UI_TEXT.acpAuthKeyName, + description: UI_TEXT.acpAuthKeyDetail, + args, + command: `${ACP_AGENT_NAME} ${args.join(' ')}`, + } + } + const { id, args } = ACP_AUTH_METHODS.museCodeLogin + return { + id, + name: UI_TEXT.acpAuthMuseCodeName, + description: UI_TEXT.acpAuthMuseCodeDetail, + args, + command: `${ACP_AGENT_NAME} ${args.join(' ')}`, + } +} + +function runtimeFor(options: ServeOptions, log: Logger) { + return createRuntimeBackend({ + options, + version: packageVersion(), + distDir, + platform: process.platform, + env: process.env, + homeDir: homedir(), + secrets, + runGit: processGitRunner(), + museCodeCredentials, + fetch: globalThis.fetch.bind(globalThis), + sleep, + log, + }) +} + +async function serve(options: ServeOptions, log: Logger): Promise { + const runtime = runtimeFor(options, log) + // A proxy the Model API backend's requests will not use is said at once (Q66). + const proxyWarning = envProxyWarning({ + backend: options.backend, + platform: process.platform, + env: process.env, + execArgv: process.execArgv, + nodeVersion: process.version, + }) + if (proxyWarning !== undefined) { + log.warn(proxyWarning) + } + // "Allow always" lapses for a paid feature started without its flag (M58). + await runtime.forgetUnflaggedGrants() + const agent = createAcpAgent({ + backend: runtime.backend, + version: packageVersion(), + options: { + canBypass: options.canBypass, + allowsContributorModels: options.allowsContributorModels, + initialMode: SETTING_DEFAULTS.initialPermissionMode, + }, + signIn: signInMethod(options), + defaultCwd: process.cwd(), + paid: runtime.paid, + log, + }) + const connection = agent.connect( + ndJsonStream(Writable.toWeb(process.stdout), webReadable(process.stdin)), + ) + log.info(`${ACP_AGENT_NAME} ${packageVersion()} serving ACP on stdio (${options.backend})`) + await connection.closed + await runtime.close() + return 0 +} + +function logLevel(command: ReturnType): LogLevel { + if (command.command !== 'serve') { + return 'warn' + } + return command.options.isVerbose ? 'trace' : 'info' +} + +async function main(): Promise { + const command = parseCommandLine(process.argv.slice(2)) + const log = stderrLogger((line) => { + writeLine(process.stderr, line) + }, logLevel(command)) + // The language's table goes in before anything reads the text (D33). + await loadUiTable({ + language: displayLanguage(process.env, new Intl.DateTimeFormat().resolvedOptions().locale), + readExtensionFile: (segments) => readFile(path.join(packageRoot, ...segments), 'utf8'), + log, + }) + switch (command.command) { + case 'serve': { + return await serve(command.options, log) + } + case 'login': { + const { museCode } = runtimeFor(command.options, log) + return await login({ + resolveLaunch: () => museCode.resolveLaunch(), + environment: () => museCode.childEnvironment(), + spawnInTerminal: (file, args, env) => + // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- `muse login` as `muse serve` is started: the CLI resolved from its install layout, PATH or an absolute --muse-binary (D1a, D4), its launcher's fixed prefix and MUSE_LOGIN_ARGS, as an argument array with no shell (PLAN.md §8) + spawn(file, [...args], { env, stdio: 'inherit' }), + printError: (line) => { + writeLine(process.stderr, line) + }, + }) + } + case 'authSet': { + return await authSet(authDeps()) + } + case 'authStatus': { + return await authStatus(authDeps()) + } + case 'authClear': { + return await authClear(authDeps()) + } + case 'version': { + writeLine(process.stdout, packageVersion()) + return 0 + } + case 'help': { + writeLine(process.stdout, fill(UI_TEXT.acpUsage, { command: ACP_AGENT_NAME })) + return 0 + } + case 'invalid': { + writeLine(process.stderr, command.reason) + writeLine(process.stderr, fill(UI_TEXT.acpUsage, { command: ACP_AGENT_NAME })) + return EXIT_FAILED + } + } +} + +/** The process's exit code is the command's; a crash prints its stack and fails. */ +async function run(): Promise { + try { + process.exitCode = await main() + } catch (error: unknown) { + writeLine( + process.stderr, + error instanceof Error ? (error.stack ?? error.message) : String(error), + ) + process.exitCode = EXIT_FAILED + } +} + +void run() diff --git a/src/runtime/paidGrants.ts b/src/runtime/paidGrants.ts new file mode 100644 index 00000000..8b09b68b --- /dev/null +++ b/src/runtime/paidGrants.ts @@ -0,0 +1,144 @@ +// "Allow always" for the ACP agent (M58, D48, D62). Each feature has a +// revocation generation, and each workspace a grant for that generation. +// Independent grants never rewrite a shared map; a writer begun before a +// revocation can finish later without restoring the old permission. Reads +// sample the generation around the grant, with no cached authorization. +// The earlier whole-file map is ignored: its grants ask again. + +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { link, rm } from 'node:fs/promises' +import path from 'node:path' +import * as z from 'zod/mini' +import type { PaidGrantStore } from '../acp/paid' +import type { CoreLogger } from '../core/logging' +import { describeStoreError, storeErrorCode } from '../host/backend/storeErrors' +import { writeFileAtomically } from '../host/fsAtomic' +import { ATOMIC_TEMPORARY_SUFFIX, PAID_FEATURES, type PaidFeature } from '../shared/constants' +import { workspaceKey } from './dataFolder' + +export interface PaidGrantFileDeps { + /** The legacy map's path; the authoritative records are beside it in `.d`. */ + readonly file: string + readonly log: CoreLogger + /** Waits between rename attempts (fsAtomic); injectable so tests do not sleep. */ + readonly sleep: (ms: number) => Promise +} + +const ENOENT = 'ENOENT' +const EEXIST = 'EEXIST' +const GENERATION_FILE = 'generation.json' +const generationSchema = z.object({ id: z.uuid(), isInitial: z.boolean() }) +const grantSchema = z.uuid() +type Generation = z.infer + +export function paidGrantFile(deps: PaidGrantFileDeps): PaidGrantStore { + const featureFolder = (feature: PaidFeature) => path.join(`${deps.file}.d`, feature) + const generationFile = (feature: PaidFeature) => + path.join(featureFolder(feature), GENERATION_FILE) + const grantFile = (workspaceRoot: string, feature: PaidFeature, generation: string) => + path.join(featureFolder(feature), `${workspaceKey(workspaceRoot)}.${generation}.json`) + + /** A missing record grants nothing; damaged or unreadable records fail closed. */ + const read = (file: string, schema: z.ZodMiniType, isChanging = false): T | undefined => { + try { + const text = readFileSync(file, 'utf8') + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + throw new Error('not valid JSON') + } + const parsed = schema.safeParse(raw) + if (!parsed.success) { + throw new Error('not a valid paid-use record') + } + return parsed.data + } catch (error: unknown) { + if (storeErrorCode(error) === ENOENT) { + return undefined + } + const reason = describeStoreError(error) + if (isChanging) { + throw new Error(`${file} could not be read: ${reason}`, { cause: error }) + } + deps.log.warn(`Paid-use grants in ${file} ignored: ${reason}`) + return undefined + } + } + + const write = (file: string, record: string | Generation) => + writeFileAtomically(file, `${JSON.stringify(record)}\n`, { sleep: deps.sleep }) + + /** + * Captures an existing generation before any await. The first generation + * is published complete with a no-replace hard link, so concurrent first + * writers cannot overwrite one another or expose half a record. A writer + * that began before a revocation cannot join the replacement generation. + * A crash's leftover temporary record grants nothing. + */ + const generationFor = async (feature: PaidFeature): Promise => { + const file = generationFile(feature) + const existing = read(file, generationSchema, true) + if (existing !== undefined) { + return existing + } + const created: Generation = { id: randomUUID(), isInitial: true } + const temporary = `${file}.${created.id}${ATOMIC_TEMPORARY_SUFFIX}` + try { + await write(temporary, created) + try { + await link(temporary, file) + return created + } catch (error: unknown) { + if (storeErrorCode(error) !== EEXIST) { + throw error + } + const winner = read(file, generationSchema, true) + if (winner?.isInitial !== true) { + throw new Error('Paid-use grants were revoked while this grant was being initialized', { + cause: error, + }) + } + return winner + } + } finally { + await rm(temporary, { force: true }) + } + } + + const add = async (workspaceRoot: string, feature: PaidFeature): Promise => { + const generation = await generationFor(feature) + // The generation came through zod (or randomUUID) before entering a path. + // A stale writer cannot replace a newer generation's explicit grant. + await write(grantFile(workspaceRoot, feature, generation.id), generation.id) + } + + return { + read: (workspaceRoot) => + new Set( + PAID_FEATURES.filter((feature) => { + const file = generationFile(feature) + const before = read(file, generationSchema) + if ( + before === undefined || + read(grantFile(workspaceRoot, feature, before.id), grantSchema) !== before.id + ) { + return false + } + // Another process may revoke while the grant is read. + return read(file, generationSchema)?.id === before.id + }), + ), + add: async (workspaceRoot, features) => { + await Promise.all(features.map((feature) => add(workspaceRoot, feature))) + }, + forget: async (features) => { + await Promise.all( + features.map((feature) => + write(generationFile(feature), { id: randomUUID(), isInitial: false }), + ), + ) + }, + } +} diff --git a/src/runtime/proxyWarning.ts b/src/runtime/proxyWarning.ts new file mode 100644 index 00000000..00d863bd --- /dev/null +++ b/src/runtime/proxyWarning.ts @@ -0,0 +1,88 @@ +// The agent's own requests and a proxy (PLAN.md D62, Q66). The Model API +// backend reaches Meta through Node's `fetch`, which ignores the proxy +// variables unless Node's own switch is on (NODE_USE_ENV_PROXY=1, or +// --use-env-proxy), and only Node 22.21 and later on the 22 line, and every +// release from 24, have that switch (measured 2026-09-27, +// docs/certification/pr32-integration.md). The owner's ruling: the agent +// does not re-route by itself; it says so, once, at start, when a proxy +// variable is set for the Model API backend and nothing will use it. Only +// the variables' names are logged: a proxy's address can hold credentials. + +import { + type AcpBackendKind, + NODE_ENV_PROXY, + NODE_OPTIONS_VARIABLE, + NODE_PROXY_VARIABLES, +} from '../shared/constants' + +export interface EnvProxyInput { + readonly backend: AcpBackendKind + /** Windows reads a variable whatever its case, so one name may answer to both spellings. */ + readonly platform: NodeJS.Platform + readonly env: NodeJS.ProcessEnv + /** `process.execArgv`: Node's own flags for this process. */ + readonly execArgv: readonly string[] + /** `process.version`, such as "v22.20.0". */ + readonly nodeVersion: string +} + +const VERSION = /^v?(\d+)\.(\d+)/ +const OPTION_SEPARATOR = /\s+/ + +/** Whether this Node's `fetch` can use the environment's proxy at all. */ +function hasEnvProxySwitch(nodeVersion: string): boolean { + const match = VERSION.exec(nodeVersion) + if (match === null) { + return false + } + const major = Number(match[1]) + const minor = Number(match[2]) + const { allFromMajor, lineMajor, lineMinor } = NODE_ENV_PROXY.since + return major >= allFromMajor || (major === lineMajor && minor >= lineMinor) +} + +/** Whether the switch is on: the variable, or the flag given to Node directly or in NODE_OPTIONS. */ +function isEnvProxyOn(input: EnvProxyInput): boolean { + const options = (input.env[NODE_OPTIONS_VARIABLE] ?? '').split(OPTION_SEPARATOR) + return ( + input.env[NODE_ENV_PROXY.variable] === NODE_ENV_PROXY.on || + input.execArgv.includes(NODE_ENV_PROXY.flag) || + options.includes(NODE_ENV_PROXY.flag) + ) +} + +/** The proxy variables set, each once: on Windows `HTTPS_PROXY` and `https_proxy` are one. */ +function proxyVariablesSet(input: EnvProxyInput): readonly string[] { + const names: string[] = [] + const seen = new Set() + for (const name of NODE_PROXY_VARIABLES) { + const key = input.platform === 'win32' ? name.toUpperCase() : name + if ((input.env[name] ?? '') === '' || seen.has(key)) { + continue + } + seen.add(key) + names.push(name) + } + return names +} + +/** The one warning to log at start, or undefined when the proxy is used or none is set. */ +export function envProxyWarning(input: EnvProxyInput): string | undefined { + if (input.backend !== 'modelApi') { + return undefined + } + const names = proxyVariablesSet(input) + if (names.length === 0) { + return undefined + } + const set = `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'}` + const guide = 'docs/acp.md, "Networks and proxies"' + if (!hasEnvProxySwitch(input.nodeVersion)) { + const { allFromMajor, lineMajor, lineMinor } = NODE_ENV_PROXY.since + const since = `${String(lineMajor)}.${String(lineMinor)} or later, or ${String(allFromMajor)}` + return `${set} set, but Node ${input.nodeVersion} cannot send the Model API backend's requests through a proxy: they go to Meta directly, bypassing it. Node ${since}, can, with ${NODE_ENV_PROXY.variable}=${NODE_ENV_PROXY.on} in the agent's environment (${guide}).` + } + return isEnvProxyOn(input) + ? undefined + : `${set} set, but ${NODE_ENV_PROXY.variable} is not ${NODE_ENV_PROXY.on}: the Model API backend's requests go to Meta directly, bypassing the proxy. Set ${NODE_ENV_PROXY.variable}=${NODE_ENV_PROXY.on} in the agent's environment and restart it (${guide}).` +} diff --git a/src/runtime/stderrLog.ts b/src/runtime/stderrLog.ts new file mode 100644 index 00000000..67f45758 --- /dev/null +++ b/src/runtime/stderrLog.ts @@ -0,0 +1,33 @@ +// The agent's log (PLAN.md D62): stdout is the protocol, so everything else +// goes to stderr, where the editors show an agent's log. Redacted by the +// same logger the panel uses. Serving logs from `info` (`trace` with +// `--verbose`); the sign-in commands only warnings, so their output stays +// what the user asked for. + +import { createLogger, type Logger } from '../host/logger' + +export type LogLevel = 'trace' | 'info' | 'warn' + +const LEVELS: readonly LogLevel[] = ['trace', 'info', 'warn'] + +export function stderrLogger(write: (line: string) => void, level: LogLevel): Logger { + const isShown = (line: LogLevel) => LEVELS.indexOf(line) >= LEVELS.indexOf(level) + return createLogger({ + trace(message) { + if (isShown('trace')) { + write(`[trace] ${message}`) + } + }, + info(message) { + if (isShown('info')) { + write(`[info] ${message}`) + } + }, + warn(message) { + write(`[warn] ${message}`) + }, + error(message) { + write(`[error] ${message}`) + }, + }) +} diff --git a/src/runtime/webStreams.ts b/src/runtime/webStreams.ts new file mode 100644 index 00000000..465b6db6 --- /dev/null +++ b/src/runtime/webStreams.ts @@ -0,0 +1,44 @@ +// A Node stream as the web `ReadableStream` the ACP SDK reads (PLAN.md D62). +// Node's own `Readable.toWeb` is typed as `stream/web`'s class, not the +// global one the SDK takes. The SDK cancels the stream when its connection +// closes, after which the source's end must not close it again: once the +// stream is done, or cancelled, the source is let go. + +import type { Readable } from 'node:stream' + +export function webReadable(source: Readable): ReadableStream { + let isDone = false + const listeners: { onData?: (chunk: Buffer) => void } = {} + const release = () => { + isDone = true + if (listeners.onData !== undefined) { + source.off('data', listeners.onData) + } + } + return new ReadableStream({ + start(controller) { + listeners.onData = (chunk) => { + controller.enqueue(chunk) + } + source.on('data', listeners.onData) + source.once('end', () => { + if (isDone) { + return + } + release() + controller.close() + }) + source.once('error', (error) => { + if (isDone) { + return + } + release() + controller.error(error) + }) + }, + cancel() { + release() + source.pause() + }, + }) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6016b5f4..4fd46ffd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -140,10 +140,10 @@ export const HTTP_SETTINGS_SECTION = 'http' export const HTTP_PROXY_SETTING = 'proxy' export const HTTP_NO_PROXY_SETTING = 'noProxy' // VS Code's network settings the Diagnostics report states (M56, PLAN.md -// D43). VS Code 1.125 and later route an extension's global `fetch` and -// `WebSocket` through its proxy support and the operating system's -// certificates while these allow it; the extension relies on that rather -// than a proxy client of its own. +// D43). VS Code routes an extension's global `fetch` (every version from the +// 1.99 floor) and `WebSocket` (from 1.112.0) through its proxy support and +// the operating system's certificates while these allow it; the extension +// relies on that rather than a proxy client of its own. export const HTTP_POSTURE_SETTINGS = { proxySupport: 'proxySupport', proxyStrictSsl: 'proxyStrictSSL', @@ -152,6 +152,18 @@ export const HTTP_POSTURE_SETTINGS = { fetchAdditionalSupport: 'fetchAdditionalSupport', webSocketAdditionalSupport: 'webSocketAdditionalSupport', } as const +// Each global, and the global VS Code's extension host sets beside it when it +// installs its proxy-aware version (`proxyResolver.ts`: `fetch` at 1.99.0 +// and after, `WebSocket` from 1.112.0 with `@vscode/proxy-agent` 0.39.1). +// Diagnostics reads them to say whether this editor routes each one at all +// (M62, PLAN.md D43): VS Code 1.101 to 1.111 have a WebSocket they do not +// route, and an editor that does not run VS Code's extension host routes +// neither. +export const VSCODE_ROUTED_GLOBALS = { + fetch: { name: 'fetch', marker: '__vscodeOriginalFetch' }, + webSocket: { name: 'WebSocket', marker: '__vscodeOriginalWebSocket' }, +} as const +export const VSCODE_WEBSOCKET_ROUTED_SINCE = '1.112' // VS Code's defaults for the settings above, for a value it does not report. export const HTTP_PROXY_SUPPORT_DEFAULT = 'override' export const HTTP_PROXY_SUPPORT_MODES = ['off', 'on', 'fallback', 'override'] as const @@ -171,6 +183,25 @@ export const PROXY_VARIABLE_SPELLINGS = [ 'http_proxy', 'all_proxy', ] as const +// Node's own switch for `fetch` and a proxy (the ACP agent, PLAN.md D62, +// Q66): only "1" turns the variable on; the flag works on the command line +// or in NODE_OPTIONS; Node 22.21 on the 22 line and every release from 24 +// have it (23 never did). Measured 2026-09-27 against a local proxy. +export const NODE_ENV_PROXY = { + variable: 'NODE_USE_ENV_PROXY', + on: '1', + flag: '--use-env-proxy', + since: { lineMajor: 22, lineMinor: 21, allFromMajor: 24 }, +} as const +export const NODE_OPTIONS_VARIABLE = 'NODE_OPTIONS' +// The proxy variables Node reads with the switch on (HTTPS_PROXY falls back to +// HTTP_PROXY); ALL_PROXY is not among them. +export const NODE_PROXY_VARIABLES = [ + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', +] as const export const NO_PROXY_VARIABLE = 'NO_PROXY' export const NO_PROXY_SPELLINGS = [NO_PROXY_VARIABLE, 'no_proxy'] as const export const NO_PROXY_SEPARATOR = ',' @@ -966,6 +997,62 @@ export const MODEL_API_OUTPUT_ENCODING = 'utf8' // Model API sessions persist as one JSON file each under the workspace // storage directory (PLAN.md D14); the version guards the shape. export const MODEL_API_SESSIONS_DIR = 'modelapi-sessions' +// --- The ACP agent (M63, PLAN.md D61, D62) --- +// The executable other editors run, and how it names itself to them. +export const ACP_AGENT_NAME = 'muse-spark-code-acp' +export const ACP_AGENT_TITLE = 'Muse Spark Code (Unofficial)' +// The OS credential store's entry for the Model API key (D61); the account +// is the name the extension's SecretStorage uses (SECRET_KEYS.modelApiKey). +export const KEYRING_SERVICE = 'Muse Spark Code (Unofficial)' +// Which account pays is chosen at launch, never guessed (D62). +export const ACP_BACKENDS = ['museCode', 'modelApi'] as const +export type AcpBackendKind = (typeof ACP_BACKENDS)[number] +export const ACP_DEFAULT_BACKEND: AcpBackendKind = 'museCode' +// The terminal sign-ins `initialize` offers: the ids, and the arguments the +// client runs the agent with for each. +export const ACP_AUTH_METHODS = { + museCodeLogin: { id: 'muse-code-login', args: ['login'] }, + modelApiKey: { id: 'model-api-key', args: ['auth', 'set'] }, +} as const +export const ACP_CONFIG_IDS = { model: 'model', effort: 'effort' } as const +// The paid Model API features the agent can use (M63c, PLAN.md D30): each +// only with its flag, and each use asked in the editor (M58, D48). Muse +// Voice needs the panel's microphone, so the agent has none. +export const ACP_PAID_FEATURES = [ + 'webSearch', + 'imageGeneration', +] as const satisfies readonly PaidFeature[] +export type AcpPaidFeature = (typeof ACP_PAID_FEATURES)[number] +export const ACP_PAID_FLAGS = { + webSearch: 'web-search', + imageGeneration: 'image-generation', +} as const satisfies Readonly> +// The question before each paid use (M58, PLAN.md D48): its tool call row (a +// count appended) and its answers. +export const ACP_PAID_TOOL_CALL_PREFIX = 'paid-use-' +export const ACP_PAID_OPTIONS = { + allowOnce: 'paid-allow-once', + allowAlways: 'paid-allow-always', + deny: 'paid-deny', +} as const +// A tool's output as the client sees it; the full text stays with the backend. +export const ACP_TOOL_OUTPUT_MAX_CHARS = 20_000 +export const ACP_SESSION_LIST_LIMIT = 50 +// Model API sessions of the agent, per folder, under the user's data folder: +// the folder named per platform, and the length of the folder's hash. +export const ACP_DATA_FOLDER = { + win32: 'Muse Spark Code', + darwin: 'Muse Spark Code', + other: 'muse-spark-code', +} as const +export const ACP_SESSIONS_SUBFOLDER = 'acp' +export const ACP_WORKSPACE_HASH_CHARS = 16 +// "Allow always in this workspace" for paid uses (M58), every folder's in one +// file beside the folders' own, keyed by the same hash. +export const ACP_PAID_GRANTS_FILE = 'paid-uses.json' +// The file walk that stands in for VS Code's file search when git cannot +// list a folder: what it never descends into. +export const FILE_WALK_SKIPPED: ReadonlySet = new Set(['.git', 'node_modules']) export const STORED_SESSION_VERSION = 1 // PLAN.md D26: a session store `.tmp` this old is a crash's leftover, not a // save in flight (another window on the same workspace may be writing one). @@ -1170,6 +1257,8 @@ export const CHAT_REFERENCE_LABEL_CHARS = 60 export const IDE_CONTEXT_TAGS = { selection: 'ide_selection', openedFile: 'ide_opened_file', + // A file or excerpt an ACP client attached to the prompt (M63). + attachedContext: 'ide_attached_context', } as const // Virtual documents holding a file's pre-edit text for the diff view. export const MUSE_EDIT_SCHEME = 'muse-edit' @@ -1547,6 +1636,9 @@ export const WINDOWS_PSMODULEPATH_VARIABLE = 'PSModulePath' * that app. */ export const DICTATION_DARWIN_APP_NAME_FLAG = '--app-name' +// The ACP agent's `login` (PLAN.md D62) runs Muse Code's own terminal sign-in; +// the panel signs in through MSP's device code instead (M55). +export const MUSE_LOGIN_ARGS = ['login'] as const export const MUSE_LOGOUT_ARGS = ['logout'] as const // `Muse Spark: Open in Terminal` runs the CLI with no arguments (its TUI). export const MUSE_TERMINAL_NAME = 'Muse Code' diff --git a/src/shared/l10n/en.ts b/src/shared/l10n/en.ts index 968fa1a0..7ebff227 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -1100,10 +1100,66 @@ export const EN = { modelApiNeedsFolder: 'Open a folder first; the Model API backend works inside a workspace.', modelApiBundleUnavailable: 'The Model API backend could not be loaded; reinstall the extension and reload the window. The log has the details.', + // The same in the ACP agent (D62), whose package ships the bundle. + acpModelApiBundleUnavailable: + 'The Model API backend could not be loaded; reinstall muse-spark-code-acp and restart the agent. The agent’s log has the details.', // Why the Muse Code CLI was not found, on the sign-in page and in warnings. cliNotFound: 'Muse Code is not installed in any known location.', cliPathNotAbsolute: 'museSpark.museBinaryPath must be an absolute path.', cliSearched: 'Searched: {paths}', + // The ACP agent in other editors (M63, PLAN.md D61, D62): its sign-ins, + // the key's commands, its errors and its help. + acpAuthMuseCodeName: 'Sign in to Muse Code', + acpAuthMuseCodeDetail: + 'Runs Muse Code’s own sign-in in a terminal. Your Muse subscription pays for the conversations.', + acpAuthKeyName: 'Store a Meta Model API key', + acpAuthKeyDetail: + 'Reads your key in a terminal and keeps it in this computer’s credential store. The key is billed for the conversations.', + // {command}: the sign-in command, for a client that cannot run it itself. + acpSignInByHand: 'Run “{command}” in a terminal, then try again.', + acpMuseCodeSignedOut: 'Muse Code is not signed in; sign in and try again.', + acpNoStoredKey: 'No Meta Model API key is stored; store one and try again.', + acpKeyPrompt: 'Meta Model API key (not shown as you type): ', + // {store}: where the key lives (acpStoreNames). + acpKeyStored: 'The key is stored in {store}.', + acpKeyNotStored: 'No key was entered, so nothing was stored.', + acpKeyPresent: 'A Meta Model API key is stored in {store}.', + acpKeyAbsent: 'No Meta Model API key is stored.', + acpKeyCleared: 'The Meta Model API key was removed from this computer’s credential store.', + // {reason}: the operating system's own error. + acpStoreUnavailable: + 'This computer’s credential store cannot be used ({reason}). On Linux the agent needs a running, unlocked Secret Service, such as GNOME Keyring or KWallet.', + acpStoreNames: { + windows: 'Windows Credential Manager', + macos: 'the macOS Keychain', + linux: 'the Secret Service keyring', + }, + acpNoModels: 'The backend offers no model this agent may use.', + acpPromptBusy: 'A prompt is already running in this session.', + acpQuestionFormMessage: 'Muse has a question for you.', + acpQuestionAsked: + 'Muse has a question; this editor cannot show it as a form, so answer in your next message:', + acpUnknownArgument: 'Unknown argument: {argument}', + // {argument}: the paid feature's flag as typed. + acpPaidNeedsModelApi: '{argument} needs --backend modelApi: paid features bill a Model API key.', + // {command}: the executable's name. The options and values stay as typed. + acpUsage: [ + 'Usage:', + ' {command} [options] Serve the Agent Client Protocol on stdin and stdout', + ' {command} [options] login Sign in to Muse Code in this terminal', + ' {command} auth set|status|clear Store, check or remove the Meta Model API key', + 'Options:', + ' --backend museCode|modelApi Who pays: Muse Code (the default) or the Model API key', + ' --trust-workspace Load the folder’s rules, skills and memory', + ' --muse-binary The Muse Code CLI to run', + ' --shell-sandbox auto|muse|off Muse Code’s shell sandbox', + ' --allow-dangerously-skip-permissions Offer the Bypass permissions mode', + ' --allow-contributor-models List contributor-tier models (Meta may train on their content)', + ' --web-search Offer paid web search (Model API backend; its price is asked first)', + ' --image-generation Offer paid image generation (Model API backend; its price is asked first)', + ' --verbose Log every detail on stderr', + ' --help, --version', + ].join('\n'), // The exported Markdown's own words (M30); what was said and run is copied as it was. exportSessionLine: 'Session: `{id}`', exportBackendLine: 'Backend: {backend}', @@ -1331,6 +1387,14 @@ export const EN = { 'The proxy refused the connection (HTTP {status}). Check that it allows api.meta.ai.', networkUnreachable: 'Meta’s server could not be reached. Check the network connection, and http.proxy and http.proxySupport if you use a proxy.', + // The same three in the ACP agent (PLAN.md D62, Q66), where VS Code's + // settings do not reach: they name the agent's environment variables. + acpNetworkUntrustedCertificate: + 'The server’s certificate is not trusted. If your network inspects HTTPS, name its root certificate’s file in NODE_EXTRA_CA_CERTS in the agent’s environment, or add --use-system-ca to NODE_OPTIONS there (Node 22.15 or later) to trust the operating system’s store, then restart the agent.', + acpNetworkProxyCredentials: + 'The proxy asked for credentials and did not accept the ones it got. Check the user name and password in the proxy’s address in HTTPS_PROXY (http://user:password@host:port) in the agent’s environment, then restart the agent.', + acpNetworkUnreachable: + 'Meta’s server could not be reached. Check the network connection. Behind a proxy, set HTTPS_PROXY and NODE_USE_ENV_PROXY=1 in the agent’s environment (Node 22.21 or later, or 24) and restart the agent: without NODE_USE_ENV_PROXY the agent does not use the proxy.', // Muse Code refused a permission mode above the ceiling its configuration sets. approvalModeCeiling: 'Muse Code’s configuration (its default permission profile, or a policy your administrator manages) does not allow this permission mode. Choose a stricter one, such as Manual, and send again.', diff --git a/src/webview/hostBridge.ts b/src/webview/hostBridge.ts new file mode 100644 index 00000000..47d1dacc --- /dev/null +++ b/src/webview/hostBridge.ts @@ -0,0 +1,35 @@ +// The webview's one way to its host (M61, PLAN.md D60): posting to it, the +// state a reload comes back with, and where its messages arrive. VS Code's +// bridge wraps `acquireVsCodeApi()`, which a webview may call only once, and +// takes the host's messages as `message` events on the window. Another host +// (a JCEF or WebView2 panel) supplies a bridge of its own and the app does +// not change. + +import type { WebviewToHostMessage } from '../shared/protocol' +import type { WebviewState } from './state/snapshot' + +/** Where the host's messages arrive: `message` events whose `data` is the message. */ +export type MessageSource = Pick + +export interface HostBridge { + post(message: WebviewToHostMessage): void + /** The state saved before the document last went away, unvalidated (`restoredUiState` checks it). */ + savedState(): unknown + saveState(state: WebviewState): void + readonly messages: MessageSource +} + +/** VS Code's bridge; `messages` is the window VS Code posts the host's messages to. */ +export function vsCodeHostBridge(messages: MessageSource): HostBridge { + const api = acquireVsCodeApi() + return { + post: (message) => { + api.postMessage(message) + }, + savedState: () => api.getState(), + saveState: (state) => { + api.setState(state) + }, + messages, + } +} diff --git a/src/webview/main.tsx b/src/webview/main.tsx index 5a8bf359..04a1ffc6 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -1,22 +1,23 @@ -// Webview entry: mounts the React app and bridges postMessage to the host. -// The UI store lives here, outside the error boundary (M25): it starts from -// the conversation this panel saved in VS Code's webview state, keeps -// reducing host messages while the crash screen shows, and is saved again -// (throttled, and at once before a reload) so the crash screen's Reload -// comes back with the conversation. The display language's table goes in -// before anything reads the text (PLAN.md D33). +// Webview entry: mounts the React app and connects it to VS Code through the +// host bridge (hostBridge.ts, M61). The UI store lives here, outside the +// error boundary (M25): it starts from the conversation this panel saved in +// VS Code's webview state, keeps reducing host messages while the crash +// screen shows, and is saved again (throttled, and at once before a reload) +// so the crash screen's Reload comes back with the conversation. The display +// language's table goes in before anything reads the text (PLAN.md D33). import { createRoot } from 'react-dom/client' import { WEBVIEW_ROOT_ELEMENT_ID } from '../shared/constants' import { App } from './App' import { ErrorBoundary } from './components/ErrorBoundary' import { type ErrorReporter, webviewErrorReport } from './errorReport' +import { vsCodeHostBridge } from './hostBridge' import { installEmbeddedTable } from './installTable' import { restoredUiState } from './state/snapshot' import { createUiStore, listenToHost, persistStore } from './state/store' import './styles.css' -const vscode = acquireVsCodeApi() +const host = vsCodeHostBridge(window) const rootElement = document.querySelector(`#${WEBVIEW_ROOT_ELEMENT_ID}`) if (rootElement === null) { throw new Error(`Webview root element #${WEBVIEW_ROOT_ELEMENT_ID} is missing`) @@ -25,7 +26,7 @@ if (rootElement === null) { // What throws here reaches the host's log (M39): a render the boundary // caught, an error or a rejected promise nothing handled, a host message. const report: ErrorReporter = (source, error) => { - vscode.postMessage(webviewErrorReport(source, error)) + host.post(webviewErrorReport(source, error)) } window.addEventListener('error', (event) => { const error: unknown = event.error ?? event.message @@ -42,10 +43,10 @@ if (tableError !== undefined) { report('hostMessage', tableError) } -const store = createUiStore(restoredUiState(vscode.getState())) -listenToHost(store, window, () => Date.now(), report) +const store = createUiStore(restoredUiState(host.savedState())) +listenToHost(store, host.messages, () => Date.now(), report) const persister = persistStore(store, (state) => { - vscode.setState(state) + host.saveState(state) }) // The document goes away (a reload, the panel closing): save what is shown. window.addEventListener('pagehide', () => { @@ -61,13 +62,13 @@ createRoot(rootElement).render( // A state that crashed the very first render would crash the reloaded // one too: it is saved without its transcript then. persister.flush(store.hasRendered()) - vscode.postMessage({ type: 'hostAction', action: 'reload' }) + host.post({ type: 'hostAction', action: 'reload' }) }} > { - vscode.postMessage(message) + host.post(message) }} /> , diff --git a/src/webview/state/store.ts b/src/webview/state/store.ts index d8e14283..196d5c28 100644 --- a/src/webview/state/store.ts +++ b/src/webview/state/store.ts @@ -8,6 +8,7 @@ import { WEBVIEW_STATE_SAVE_MS } from '../../shared/constants' import { parseHostToWebviewMessage } from '../../shared/protocol' import type { ErrorReporter } from '../errorReport' +import type { MessageSource } from '../hostBridge' import { webviewStateOf, type WebviewState } from './snapshot' import { type UiAction, uiReducer, type UiState } from './uiState' @@ -60,7 +61,7 @@ export function createUiStore(initial: UiState): UiStore { */ export function listenToHost( store: UiStore, - target: Window, + target: MessageSource, now: () => number, report: ErrorReporter, ): () => void { diff --git a/test/e2e/acpStdio.e2e.test.ts b/test/e2e/acpStdio.e2e.test.ts new file mode 100644 index 00000000..8b375940 --- /dev/null +++ b/test/e2e/acpStdio.e2e.test.ts @@ -0,0 +1,430 @@ +// The ACP agent as editors run it (M63, PLAN.md D62): `acp.js` bundled from +// the source as the build bundles it (or, with MUSE_ACP_PACKAGE_DIR, the +// package npm installed, as hosts.yml checks it on each platform), started +// as a real child process, and +// driven over its stdio by the ACP SDK's own client, on the fake Muse Code +// CLI of fake-muse/serve.mjs. A reply streamed, a tool call allowed and one +// denied, a cancel, the session listed, sign-in asked for, and the key never +// on the wire. The Model API backend, which reads its key only from the OS +// credential store, is loaded from the package's own dist/modelApi.js (M57) +// in this process, and so is Muse Code's readiness, read from the CLI's +// credential file as the panel reads it (D26, PR #49). + +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { Writable } from 'node:stream' +import * as acp from '@agentclientprotocol/sdk' +import { EXPECTED_SCHEMA_FINGERPRINT } from '@muse-code/sdk' +import { build } from 'esbuild' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { AgentEvent } from '../../src/shared/agentEvents' +import { createRuntimeBackend } from '../../src/runtime/backends' +import { webReadable } from '../../src/runtime/webStreams' +import { SECRET_KEYS, UI_TEXT } from '../../src/shared/constants' +import { fill } from '../../src/shared/l10n/text' +import { DEVICE_LOGIN_FILE, LOGOUT_SHELL } from '../unit/helpers/credentialShapes' +import { memorySecrets } from '../unit/helpers/fakes' +import { fakeModelApi } from '../unit/helpers/fakeModelApi' +import { buildModelApiBundle } from '../unit/helpers/modelApiBundle' +import { removeFolder } from '../unit/helpers/temporaryFolders' +import { + fakeCredentialFile, + installFakeCredential, + installFakeMuse, + writeFakeCredential, +} from './fakeMuse' + +const TEST_TIMEOUT_MS = 30_000 +const ROOT = path.resolve(import.meta.dirname, '..', '..') +// An installed package brings its own native keyring binding. One laid out +// here as npm installs it takes the binding from this repository's +// node_modules (NODE_PATH), as an installed package finds its dependency. +const INSTALLED = process.env['MUSE_ACP_PACKAGE_DIR'] +const PACKAGE = INSTALLED ?? mkdtempSync(path.join(tmpdir(), 'acp-package-')) +const AGENT = path.join(PACKAGE, 'dist', 'acp.js') +const NODE_PATH = INSTALLED === undefined ? path.join(ROOT, 'node_modules') : '' +const LAID_OUT_VERSION = '0.0.0-e2e' + +const fake = installFakeMuse() +const signedIn = installFakeCredential() +const signedOut = mkdtempSync(path.join(tmpdir(), 'fake-muse-none-')) +// What `muse logout` leaves: the file is there, and signed out (PR #49). +const loggedOut = installFakeCredential(LOGOUT_SHELL) +// Captured on macOS (version 2, the Keychain lane); `muse serve` exits with +// it on Windows and Linux (docs/certification/sign-in-detection.md). +const MACOS_POINTER = JSON.stringify({ + schema_version: 2, + providers: { meta: { storage: 'keychain' } }, +}) +// Synthetic, as PR #49's own e2e: a `meta` entry in a lane no build was seen +// writing, beside a credential key. Only the CLI can say; the fake answers +// from the key. +const UNPLACEABLE = JSON.stringify({ + schema_version: 1, + providers: { meta: { storage: 'elsewhere', access_token: '' } }, +}) +// The same lane with no credential key: the fake CLI answers signed out. +const SIGNED_OUT_UNPLACEABLE = JSON.stringify({ + schema_version: 1, + providers: { meta: { storage: 'elsewhere' } }, +}) +const workspace = mkdtempSync(path.join(tmpdir(), 'acp-e2e-ws-')) +const dataHome = mkdtempSync(path.join(tmpdir(), 'acp-e2e-data-')) +const children: ChildProcessWithoutNullStreams[] = [] + +beforeAll(async () => { + if (INSTALLED !== undefined) { + return + } + mkdirSync(path.dirname(AGENT), { recursive: true }) + await build({ + entryPoints: [path.join(ROOT, 'src', 'runtime', 'main.ts')], + outfile: AGENT, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + external: ['@napi-rs/keyring'], + logLevel: 'silent', + }) + buildModelApiBundle(path.dirname(AGENT)) + writeFileSync(path.join(PACKAGE, 'package.json'), JSON.stringify({ version: LAID_OUT_VERSION })) + cpSync(path.join(ROOT, 'l10n'), path.join(PACKAGE, 'l10n'), { recursive: true }) +}) + +afterAll(async () => { + for (const child of children) { + child.kill() + } + const made = [fake.installDir, signedIn, signedOut, loggedOut, workspace, dataHome] + await Promise.all( + [...made, ...(INSTALLED === undefined ? [PACKAGE] : [])].map((folder) => removeFolder(folder)), + ) +}) + +function agentEnvironment(configHome: string): NodeJS.ProcessEnv { + return { + ...process.env, + // What the fake CLI needs (fakeMuse.ts), handed through the agent's own environment. + MUSE_FAKE_NODE: process.execPath, + MUSE_FAKE_FINGERPRINT: EXPECTED_SCHEMA_FINGERPRINT, + NODE_PATH, + XDG_CONFIG_HOME: configHome, + LANG: 'C', + LC_ALL: '', + } +} + +interface Session { + readonly updates: acp.SessionUpdate[] + readonly wire: string[] + readonly stderr: string[] + run(op: (client: acp.ClientContext) => Promise): Promise +} + +// The runtime's options in this process: Muse Code, nothing trusted or paid. +const MUSE_CODE_OPTIONS = { + backend: 'museCode', + trustWorkspace: false, + museBinary: '', + shellSandbox: 'off', + canBypass: false, + allowsContributorModels: false, + paidFeatures: [], + isVerbose: false, +} as const + +function startAgent( + configHome: string, + args: readonly string[] = [], + extraEnv: NodeJS.ProcessEnv = {}, +): Session { + const child = spawn( + process.execPath, + [AGENT, '--muse-binary', fake.binaryPath, '--shell-sandbox', 'off', ...args], + { env: { ...agentEnvironment(configHome), ...extraEnv }, cwd: workspace, stdio: 'pipe' }, + ) + children.push(child) + const updates: acp.SessionUpdate[] = [] + const wire: string[] = [] + const stderr: string[] = [] + child.stdout.on('data', (chunk: Buffer) => { + wire.push(chunk.toString()) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk.toString()) + }) + const client = acp + .client({ name: 'e2e' }) + .onNotification('session/update', (context) => { + updates.push(context.params.update) + }) + .onRequest('session/request_permission', (context) => { + const { command } = context.params.toolCall.rawInput as { command?: string } + const optionId = command?.includes('deny') === true ? 'abort' : 'allow_once' + return { outcome: { outcome: 'selected', optionId } } + }) + const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), webReadable(child.stdout)) + return { updates, wire, stderr, run: (op) => client.connectWith(stream, op) } +} + +async function newSession(client: acp.ClientContext): Promise { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + const { sessionId } = await client.request('session/new', { cwd: workspace, mcpServers: [] }) + return sessionId +} + +function initialize(client: acp.ClientContext) { + return client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) +} + +function text(updates: readonly acp.SessionUpdate[]): string { + return updates + .flatMap((update) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ) + .join('') +} + +describe('the ACP agent over stdio (M63)', { timeout: TEST_TIMEOUT_MS }, () => { + it('prints its version and help, and refuses an argument it does not know', () => { + const env = { ...process.env, NODE_PATH, LANG: 'C' } + const version = spawnSync(process.execPath, [AGENT, '--version'], { encoding: 'utf8', env }) + const expected = + INSTALLED === undefined + ? LAID_OUT_VERSION + : ( + JSON.parse(readFileSync(path.join(PACKAGE, 'package.json'), 'utf8')) as { + version: string + } + ).version + expect(version.stdout.trim()).toBe(expected) + const help = spawnSync(process.execPath, [AGENT, '--help'], { encoding: 'utf8', env }) + expect(help.stdout).toContain('muse-spark-code-acp auth set|status|clear') + const wrong = spawnSync(process.execPath, [AGENT, '--colour'], { encoding: 'utf8', env }) + expect(wrong.status).toBe(1) + expect(wrong.stderr).toContain('--colour') + }) + + it('streams a reply, runs an allowed tool call and skips a denied one, on Muse Code', async () => { + const agent = startAgent(signedIn) + const [reply, allowed, denied] = await agent.run(async (client) => { + const sessionId = await newSession(client) + const ask = (prompt: string) => + client.request('session/prompt', { sessionId, prompt: [{ type: 'text', text: prompt }] }) + return [await ask('hello'), await ask('tool: echo allowed'), await ask('tool: echo deny me')] + }) + expect([reply, allowed, denied]).toEqual([ + { stopReason: 'end_turn' }, + { stopReason: 'end_turn' }, + { stopReason: 'end_turn' }, + ]) + expect(text(agent.updates)).toContain('echo: hello') + const toolCalls = agent.updates.filter((update) => update.sessionUpdate === 'tool_call') + expect(toolCalls).toHaveLength(2) + expect(toolCalls[0]).toMatchObject({ + kind: 'execute', + title: expect.stringContaining('echo allowed'), + }) + const finished = agent.updates.filter( + (update) => update.sessionUpdate === 'tool_call_update' && update.status !== 'in_progress', + ) + expect( + finished.map((update) => update.sessionUpdate === 'tool_call_update' && update.status), + ).toEqual(['completed', 'failed']) + }) + + it('cancels a running turn and lists the session afterwards', async () => { + const agent = startAgent(signedIn) + const [stopped, listed] = await agent.run(async (client) => { + const sessionId = await newSession(client) + const running = client.request('session/prompt', { + sessionId, + prompt: [{ type: 'text', text: 'slow' }], + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + await client.notify('session/cancel', { sessionId }) + return [await running, await client.request('session/list', { cwd: workspace })] + }) + expect(stopped).toEqual({ stopReason: 'cancelled' }) + expect(listed.sessions.length).toBeGreaterThan(0) + }) + + it('asks for sign-in when Muse Code is signed out, and for the key on the Model API backend', async () => { + const museCode = startAgent(signedOut) + await expect(museCode.run((client) => newSession(client))).rejects.toMatchObject({ + code: -32_000, + }) + const modelApi = startAgent(signedIn, ['--backend', 'modelApi']) + // Signed out where the OS has a credential store; unavailable where it + // has none (a Linux runner without a Secret Service). Never a session. + const refused = modelApi.run((client) => newSession(client)) + await expect(refused).rejects.toSatisfy( + (error: { code?: number }) => error.code === -32_000 || error.code === -32_603, + ) + expect(modelApi.wire.join('')).not.toMatch(/LLM\|/) + }) + + it('says at start that a proxy will not be used by the Model API backend, until Node’s switch is on (Q66)', async () => { + // A port nothing is asked on: the agent sends no request before a session. + const proxy = { HTTPS_PROXY: 'http://127.0.0.1:9', NODE_USE_ENV_PROXY: '' } + const unused = startAgent(signedIn, ['--backend', 'modelApi'], proxy) + await unused.run(initialize) + const said = unused.stderr.join('') + expect(said).toContain('HTTPS_PROXY is set, but') + expect(said).toContain('go to Meta directly') + expect(said).not.toContain('127.0.0.1:9') + const used = startAgent(signedIn, ['--backend', 'modelApi'], { + ...proxy, + NODE_USE_ENV_PROXY: '1', + }) + await used.run(initialize) + expect(used.stderr.join('')).not.toContain('HTTPS_PROXY is set') + const museCode = startAgent(signedIn, [], proxy) + await museCode.run(initialize) + expect(museCode.stderr.join('')).not.toContain('HTTPS_PROXY is set') + }) + + it('hands META_API_KEY in its own environment to Muse Code only, as the extension does (D1)', async () => { + const agent = startAgent(signedOut, [], { META_API_KEY: 'LLM|1|placeholder' }) + // Signed out, but the CLI's own key variable is its credential. + await agent.run((client) => newSession(client)) + const said = agent.stderr.join('') + expect(said).toContain('META_API_KEY in the environment present') + expect(said).not.toContain('placeholder') + expect(agent.wire.join('')).not.toContain('placeholder') + }) + + it('asks for sign-in after `muse logout`, whose file stays behind (PR #49)', async () => { + const agent = startAgent(loggedOut) + await expect(agent.run((client) => newSession(client))).rejects.toMatchObject({ + code: -32_000, + }) + }) + + it('reads Muse Code’s readiness from the credential file, and asks the CLI where only it can say (PR #49)', async () => { + const configHome = mkdtempSync(path.join(tmpdir(), 'acp-readiness-')) + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + // The manager reads the process's own environment, as the agent's does. + vi.stubEnv('XDG_CONFIG_HOME', configHome) + vi.stubEnv('MUSE_FAKE_NODE', process.execPath) + vi.stubEnv('MUSE_FAKE_FINGERPRINT', EXPECTED_SCHEMA_FINGERPRINT) + vi.stubEnv('META_API_KEY', '') + // The platform the file is read for; the fake CLI answers on this one. + const runtimeReadingFor = (platform: NodeJS.Platform) => + createRuntimeBackend({ + options: { ...MUSE_CODE_OPTIONS, museBinary: fake.binaryPath }, + version: LAID_OUT_VERSION, + distDir: path.dirname(AGENT), + platform, + env: process.env, + homeDir: configHome, + secrets: memorySecrets(), + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [], + fetch: () => Promise.reject(new Error('no network')), + sleep: () => Promise.resolve(), + log, + }) + const runtime = runtimeReadingFor(process.platform) + const mac = runtimeReadingFor('darwin') + const asked = () => + log.info.mock.calls.filter(([line]) => String(line).includes('confirmed by account/read')) + .length + try { + expect(await runtime.backend.readiness(false)).toMatchObject({ state: 'signedOut' }) + writeFakeCredential(configHome, LOGOUT_SHELL) + expect(await runtime.backend.readiness(false)).toEqual({ + state: 'signedOut', + message: UI_TEXT.acpMuseCodeSignedOut, + }) + // A browser sign-in: settled by the file off macOS; there, the passive + // estimate until the user checks again. + writeFakeCredential(configHome, DEVICE_LOGIN_FILE) + expect(await runtime.backend.readiness(false)).toEqual({ state: 'ready' }) + // A file only the CLI can place: off macOS asked once and remembered; + // on macOS asked only on authenticate. authenticate always asks afresh. + writeFakeCredential(configHome, UNPLACEABLE) + const before = asked() + const asksPassively = process.platform === 'darwin' ? 0 : 1 + expect(await runtime.backend.readiness(false)).toEqual({ state: 'ready' }) + expect(await runtime.backend.readiness(false)).toEqual({ state: 'ready' }) + expect(asked()).toBe(before + asksPassively) + expect(await runtime.backend.readiness(true)).toEqual({ state: 'ready' }) + expect(asked()).toBe(before + asksPassively + 1) + // Read as macOS reads it, on any runner: a session starts no CLI, and + // authenticate asks it; its answer decides. + writeFakeCredential(configHome, DEVICE_LOGIN_FILE) + const beforeMac = asked() + expect(await mac.backend.readiness(false)).toEqual({ state: 'ready' }) + expect(asked()).toBe(beforeMac) + expect(await mac.backend.readiness(true)).toEqual({ state: 'ready' }) + expect(asked()).toBe(beforeMac + 1) + writeFakeCredential(configHome, SIGNED_OUT_UNPLACEABLE) + expect(await mac.backend.readiness(true)).toMatchObject({ state: 'signedOut' }) + if (process.platform !== 'darwin') { + writeFakeCredential(configHome, MACOS_POINTER) + expect(await runtime.backend.readiness(false)).toEqual({ + state: 'unavailable', + message: fill(UI_TEXT.cliCredentialUnsupported, { + path: fakeCredentialFile(configHome), + }), + }) + } + // With META_API_KEY in its environment `muse serve` starts whatever the file says. + writeFakeCredential(configHome, LOGOUT_SHELL) + vi.stubEnv('META_API_KEY', 'LLM|1|placeholder') + expect(await runtime.backend.readiness(false)).toEqual({ state: 'ready' }) + } finally { + vi.unstubAllEnvs() + await Promise.all([runtime.close(), mac.close()]) + await removeFolder(configHome) + } + }) + + it('ships the Model API backend beside the agent, where the runtime loads it (M57)', async () => { + const api = fakeModelApi() + api.script({ text: 'From the bundle.' }) + const secrets = memorySecrets() + secrets.values.set(SECRET_KEYS.modelApiKey, 'LLM|1|secret') + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + const runtime = createRuntimeBackend({ + options: { ...MUSE_CODE_OPTIONS, backend: 'modelApi' }, + version: LAID_OUT_VERSION, + distDir: path.dirname(AGENT), + platform: process.platform, + env: { XDG_DATA_HOME: dataHome, LOCALAPPDATA: dataHome }, + homeDir: dataHome, + secrets, + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [], + fetch: api.fetch, + sleep: () => Promise.resolve(), + log, + }) + try { + const host = await runtime.backend.hostFor(workspace) + const session = await host.startSession({ + workspaceRoot: workspace, + modelId: 'muse-spark-1.3', + approvalMode: 'promptUnmatched', + }) + const events: AgentEvent[] = [] + session.onEvent((event) => { + events.push(event) + }) + await session.sendTurn([{ type: 'text', text: 'hello' }], 'hello') + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'turnCompleted')).toBe(true) + }) + expect(JSON.stringify(events)).toContain('From the bundle.') + expect(log.error).not.toHaveBeenCalled() + } finally { + await runtime.close() + } + }) +}) diff --git a/test/hosts/code-server.mjs b/test/hosts/code-server.mjs new file mode 100644 index 00000000..ad25cd9f --- /dev/null +++ b/test/hosts/code-server.mjs @@ -0,0 +1,26 @@ +// The extension in code-server (hosts.yml, PLAN.md M62): the Muse Spark +// view opened from the activity bar, then a reply, a command allowed and +// one rejected against the fake Muse Code CLI. code-server must already +// run the installed VSIX with `museSpark.museBinaryPath` set to the fake. +// +// node test/hosts/code-server.mjs + +import process from 'node:process' +import { openBrowser, panelConversation, panelFrame, runCheck } from './lib/browser.mjs' + +const [url, workspace, shots] = process.argv.slice(2) +if (url === undefined || workspace === undefined || shots === undefined) { + throw new Error('usage: code-server.mjs ') +} + +const WORKBENCH_TIMEOUT_MS = 90_000 +const { browser, page, shot } = await openBrowser(shots) +await runCheck('code-server: a reply, a command allowed and one rejected', async () => { + await page.goto(`${url}/?folder=${encodeURIComponent(workspace)}`) + await page.waitForSelector('.monaco-workbench', { timeout: WORKBENCH_TIMEOUT_MS }) + await page.locator('.activitybar [aria-label^="Muse Spark"]').first().click() + const frame = await panelFrame(page) + await panelConversation(frame, 'code-server') +}) +await shot('code-server') +await browser.close() diff --git a/test/hosts/emacs/acp-check.el b/test/hosts/emacs/acp-check.el new file mode 100644 index 00000000..b8210473 --- /dev/null +++ b/test/hosts/emacs/acp-check.el @@ -0,0 +1,94 @@ +;;; acp-check.el --- The ACP agent through acp.el, in batch -*- lexical-binding: t -*- +;; Host check (hosts.yml, PLAN.md M63): acp.el, the protocol library +;; agent-shell is built on, drives muse-spark-code-acp on the fake Muse Code +;; CLI: initialize, a session, a reply, a command allowed, the session list. +;; Environment: ACP_EL_DIR, MUSE_ACP, FAKE_MUSE, WORKSPACE. Exits 1 on a miss. +(add-to-list 'load-path (getenv "ACP_EL_DIR")) +(require 'acp) +(require 'map) + +(defvar muse-updates nil) +(defvar muse-permissions nil) +(defvar muse-failures 0) + +(defun muse-wait (predicate seconds) + (let ((deadline (+ (float-time) seconds))) + (while (and (not (funcall predicate)) (< (float-time) deadline)) + (accept-process-output nil 0.05)) + (funcall predicate))) + +(defun muse-expect (label ok) + (princ (format "%s %s\n" (if ok "ok " "FAIL") label)) + (unless ok (setq muse-failures (1+ muse-failures)))) + +(defun muse-text (kind) + "The streamed text of every KIND update, joined." + (mapconcat (lambda (update) + (let ((content (and (equal (map-elt update 'sessionUpdate) kind) + (map-elt update 'content)))) + (if (equal (map-elt content 'type) "text") (map-elt content 'text) ""))) + (reverse muse-updates) "")) + +(let* ((client (acp-make-client :command (getenv "MUSE_ACP") + :command-params (list "--muse-binary" (getenv "FAKE_MUSE")))) + (cwd (getenv "WORKSPACE")) + (init nil) (session nil) (first-stop nil) (second-stop nil) (listed nil) (failure nil)) + (acp-subscribe-to-notifications + :client client + :on-notification (lambda (n) (let-alist n (when (equal .method "session/update") (push .params.update muse-updates))))) + (acp-subscribe-to-requests + :client client + :on-request (lambda (r) + (let-alist r + (when (equal .method "session/request_permission") + (push .params muse-permissions) + (let ((allow (seq-find (lambda (o) (equal (map-elt o 'kind) "allow_once")) .params.options))) + (acp-send-response + :client client + :response (acp-make-session-request-permission-response + :request-id .id :option-id (map-elt allow 'optionId)))))))) + (acp-send-request :client client + :request (acp-make-initialize-request :protocol-version 1) + :on-success (lambda (r) (setq init r)) + :on-failure (lambda (e) (setq failure e))) + (muse-wait (lambda () (or init failure)) 30) + (muse-expect "initialize names the agent" + (equal (map-nested-elt init '(agentInfo name)) "muse-spark-code-acp")) + (acp-send-request :client client + :request (acp-make-session-new-request :cwd cwd) + :on-success (lambda (r) (setq session r)) + :on-failure (lambda (e) (setq failure e))) + (muse-wait (lambda () (or session failure)) 30) + (let ((sid (map-elt session 'sessionId))) + (muse-expect "session/new offers the four modes" + (= 4 (length (map-nested-elt session '(modes availableModes))))) + (acp-send-request :client client + :request (acp-make-session-prompt-request + :session-id sid :prompt '(((type . "text") (text . "hello from emacs")))) + :on-success (lambda (r) (setq first-stop (map-elt r 'stopReason))) + :on-failure (lambda (e) (setq failure e))) + (muse-wait (lambda () (or first-stop failure)) 30) + (muse-expect "a reply streams and the turn ends" + (and (equal first-stop "end_turn") + (equal (muse-text "agent_message_chunk") "echo: hello from emacs"))) + (setq muse-updates nil) + (acp-send-request :client client + :request (acp-make-session-prompt-request + :session-id sid :prompt '(((type . "text") (text . "tool: echo from-emacs")))) + :on-success (lambda (r) (setq second-stop (map-elt r 'stopReason))) + :on-failure (lambda (e) (setq failure e))) + (muse-wait (lambda () (or second-stop failure)) 30) + (muse-expect "a command asks, is allowed once and runs" + (and (equal second-stop "end_turn") + (= 1 (length muse-permissions)) + (string-match-p "ran: echo from-emacs" (muse-text "agent_message_chunk")))) + (acp-send-request :client client + :request (acp-make-session-list-request :cwd cwd) + :on-success (lambda (r) (setq listed r)) + :on-failure (lambda (e) (setq failure e))) + (muse-wait (lambda () (or listed failure)) 30) + (muse-expect "session/list holds the session" + (member sid (mapcar (lambda (s) (map-elt s 'sessionId)) (map-elt listed 'sessions))))) + (when failure (muse-expect (format "no request failed (%S)" failure) nil)) + (acp-shutdown :client client) + (kill-emacs (if (zerop muse-failures) 0 1))) diff --git a/test/hosts/emacs/agent-shell-check.el b/test/hosts/emacs/agent-shell-check.el new file mode 100644 index 00000000..619ccd86 --- /dev/null +++ b/test/hosts/emacs/agent-shell-check.el @@ -0,0 +1,59 @@ +;;; agent-shell-check.el --- The ACP agent in agent-shell, in batch -*- lexical-binding: t -*- +;; Host check (hosts.yml, PLAN.md M63): the agent-shell configuration of +;; docs/acp.md starts muse-spark-code-acp on the fake Muse Code CLI; a reply, +;; a command allowed with `y' and one rejected with `C-c C-c' (which cancels +;; the turn, so the permission is answered `cancelled'). Environment: +;; ACP_EL_DIR, MUSE_ACP, FAKE_MUSE, WORKSPACE. Exits 1 on a miss. +(add-to-list 'load-path (getenv "ACP_EL_DIR")) +(require 'agent-shell) + +(defvar muse-failures 0) + +(defun muse-pump (seconds &optional predicate) + (let ((deadline (+ (float-time) seconds))) + (while (and (< (float-time) deadline) (not (and predicate (funcall predicate)))) + (accept-process-output nil 0.05)))) + +(defun muse-config () + (agent-shell-make-agent-config + :identifier 'muse-spark + :mode-line-name "Muse Spark" + :buffer-name "Muse Spark" + :shell-prompt "Muse> " + :shell-prompt-regexp "Muse> " + :client-maker (lambda (buffer) + (acp-make-client :command (getenv "MUSE_ACP") + :command-params (list "--muse-binary" (getenv "FAKE_MUSE")) + :context-buffer buffer)))) + +(let ((default-directory (file-name-as-directory (getenv "WORKSPACE")))) + (agent-shell-start :config (muse-config)) + (let* ((shell (seq-find (lambda (b) (with-current-buffer b (derived-mode-p 'agent-shell-mode))) (buffer-list))) + (text (lambda () (with-current-buffer shell (buffer-substring-no-properties (point-min) (point-max))))) + (seen (lambda (pattern) (string-match-p pattern (funcall text)))) + (expect (lambda (label pattern) + (muse-pump 20 (lambda () (funcall seen pattern))) + (let ((ok (funcall seen pattern))) + (princ (format "%s %s\n" (if ok "ok " "FAIL") label)) + (unless ok (setq muse-failures (1+ muse-failures))))))) + (unless shell (princ "FAIL no agent-shell buffer\n") (kill-emacs 1)) + (funcall expect "the agent is ready" "Ready") + (agent-shell-insert :text "hello from agent-shell" :submit t :shell-buffer shell) + (funcall expect "a reply streams" "echo: hello from agent-shell") + (agent-shell-insert :text "tool: echo allowed-in-agent-shell" :submit t :shell-buffer shell) + (funcall expect "the permission prompt shows" "Allow once") + (with-current-buffer shell + (goto-char (point-max)) + (search-backward "Allow once") + (call-interactively (key-binding (kbd "y")))) + (funcall expect "`y' allows the command once" "ran: echo allowed-in-agent-shell") + (agent-shell-insert :text "tool: echo rejected-in-agent-shell" :submit t :shell-buffer shell) + (muse-pump 20 (lambda () (string-match-p "rejected-in-agent-shell\\(.\\|\n\\)*Allow once" (funcall text)))) + (with-current-buffer shell + (goto-char (point-max)) + (search-backward "Reject") + (call-interactively (key-binding (kbd "C-c C-c")))) + (funcall expect "`C-c C-c' rejects it" "skipped: echo rejected-in-agent-shell") + (when (> muse-failures 0) + (princ (format "--- the shell buffer ---\n%s\n" (funcall text)))))) +(kill-emacs (if (zerop muse-failures) 0 1)) diff --git a/test/hosts/fake-muse.sh b/test/hosts/fake-muse.sh new file mode 100644 index 00000000..2412efa6 --- /dev/null +++ b/test/hosts/fake-muse.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# The fake Muse Code CLI for the host checks (hosts.yml), installed in DIR: +# DIR/bin/muse runs test/e2e/fake-muse/serve.mjs, and DIR/config holds the +# credential file the extension, the agent and the fake's `account/read` +# read under XDG_CONFIG_HOME: a browser sign-in's shape as captured +# (test/unit/helpers/credentialShapes.ts), placeholders only, no secret. +# Prints DIR/bin/muse. +# +# sh test/hosts/fake-muse.sh DIR +set -eu +dir="$1" +here="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$dir/bin" "$dir/config/muse" +cp "$here/../e2e/fake-muse/serve.mjs" "$dir/bin/serve.mjs" +printf '#!/usr/bin/env node\nimport("file://%s/bin/serve.mjs")\n' "$(cd "$dir" && pwd)" > "$dir/bin/muse" +chmod 755 "$dir/bin/muse" +printf '{"schema_version":1,"providers":{"meta":{"access_token":"","obtained_via":"device_code","mechanism":"oauth","api_key":"","api_base_url":""}}}\n' \ + > "$dir/config/muse/auth.json" +echo "$(cd "$dir" && pwd)/bin/muse" diff --git a/test/hosts/fork-release.mjs b/test/hosts/fork-release.mjs new file mode 100644 index 00000000..4a566652 --- /dev/null +++ b/test/hosts/fork-release.mjs @@ -0,0 +1,119 @@ +// The VS Code forks' checks (forks.yml, PLAN.md M62b), two commands: +// +// node test/hosts/fork-release.mjs latest cursor|devin-desktop|kiro|positron +// prints " " for the fork's latest Linux x64 +// release, from the feed its nixpkgs update script or Homebrew cask +// reads (Devin Desktop was Windsurf until 2026) +// node test/hosts/fork-release.mjs describe +// prints the unpacked app's executable name, then a line naming the +// fork's version and the VS Code version it is built on + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +async function getJson(url, headers = {}) { + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`${url} answered HTTP ${response.status}`) + } + return response.json() +} + +/** The field a feed must carry; its keys are named when it does not. */ +function field(json, key, feed) { + const value = json[key] + if (typeof value !== 'string' || value === '') { + throw new Error(`${feed} has no "${key}"; its keys: ${Object.keys(json).join(', ')}`) + } + return value +} + +const GITHUB_TOKEN = process.env.GH_TOKEN + +const FEEDS = { + async cursor() { + const feed = 'https://api2.cursor.sh/updates/api/download/stable/linux-x64/cursor' + const json = await getJson(feed) + return { version: field(json, 'version', feed), url: field(json, 'downloadUrl', feed) } + }, + async 'devin-desktop'() { + const feed = 'https://windsurf-stable.codeium.com/api/update/linux-x64/stable/latest' + const json = await getJson(feed) + return { version: field(json, 'windsurfVersion', feed), url: field(json, 'url', feed) } + }, + async kiro() { + const feed = 'https://prod.download.desktop.kiro.dev/stable/metadata-linux-x64-stable.json' + const json = await getJson(feed) + const archive = (json.releases ?? []) + .map((release) => release.updateTo) + .find((update) => /\.tar(?:\.|$)/.test(update?.url ?? '')) + if (archive === undefined) { + throw new Error(`${feed} lists no .tar release`) + } + return { version: field(json, 'currentRelease', feed), url: archive.url } + }, + async positron() { + const feed = 'https://api.github.com/repos/posit-dev/positron/releases?per_page=1' + const headers = GITHUB_TOKEN === undefined ? {} : { authorization: `Bearer ${GITHUB_TOKEN}` } + const [latest] = await getJson(feed, headers) + const version = field(latest ?? {}, 'tag_name', feed) + return { + version, + url: `https://cdn.posit.co/positron/releases/deb/x86_64/Positron-${version}-x64.deb`, + } + }, +} + +async function latest(fork) { + const find = Object.hasOwn(FEEDS, fork) ? FEEDS[fork] : undefined + if (find === undefined) { + throw new Error(`no feed for "${fork}"; one of: ${Object.keys(FEEDS).join(', ')}`) + } + const { version, url } = await find() + console.log(`${version} ${url}`) +} + +function readJson(file) { + return JSON.parse(readFileSync(file, 'utf8')) +} + +/** A VS Code release number: 1, then a minor of two digits or more (1.99.3, 1.128.0). */ +const VSCODE_VERSION = /^1\.\d{2,}\.\d+$/ + +function describe(root) { + const app = path.join(root, 'resources', 'app') + const product = readJson(path.join(app, 'product.json')) + const manifest = readJson(path.join(app, 'package.json')) + // Cursor names its VS Code base apart (vscodeVersion, Kiro vsCodeVersion); + // Devin Desktop and Positron keep VS Code's as the product's and their own + // under a name of their own; another fork may name it nowhere. + const vscode = [ + product.vscodeVersion, + product.vsCodeVersion, + product.version, + manifest.version, + ].find((value) => typeof value === 'string' && VSCODE_VERSION.test(value)) + const own = [ + ...new Set([product.version, product.windsurfVersion, product.positronVersion]), + ].filter((value) => typeof value === 'string' && value !== vscode) + const fields = Object.entries(product) + .filter(([key, value]) => /version/i.test(key) && typeof value === 'string') + .map(([key, value]) => `${key} ${value}`) + .join(', ') + const base = + vscode === undefined + ? `VS Code version not named; product.json has ${fields}` + : `VS Code ${vscode}` + console.log(product.applicationName) + console.log(`${[product.nameLong, ...own].join(' ')} (${base})`) +} + +const [command, argument] = process.argv.slice(2) +if (command === 'latest' && argument !== undefined) { + await latest(argument) +} else if (command === 'describe' && argument !== undefined) { + describe(argument) +} else { + throw new Error('usage: fork-release.mjs latest | describe ') +} diff --git a/test/hosts/installed.vscode-test.mjs b/test/hosts/installed.vscode-test.mjs new file mode 100644 index 00000000..e7a26088 --- /dev/null +++ b/test/hosts/installed.vscode-test.mjs @@ -0,0 +1,24 @@ +// The integration tests in an installed editor built on VS Code (hosts.yml, +// forks.yml, PLAN.md M62): the same suite as .vscode-test.mjs, run in the +// executable HOST_BIN names (VSCodium, Cursor, Kiro, ...). HOST_LABEL names +// the run. Paths are relative to this file. +import process from 'node:process' + +const executable = process.env.HOST_BIN +if (executable === undefined) { + throw new Error('HOST_BIN names the editor executable to test in') +} +const MOCHA_TIMEOUT_MS = 20_000 + +export default [ + { + label: process.env.HOST_LABEL ?? 'installed', + files: '../../dist/test/integration/**/*.test.js', + workspaceFolder: '../fixtures/workspace', + extensionDevelopmentPath: '../..', + useInstallation: { fromPath: executable }, + // An unpacked archive leaves chrome-sandbox without its setuid bit. + launchArgs: ['--disable-extensions', '--no-sandbox', '--disable-gpu'], + mocha: { ui: 'tdd', timeout: MOCHA_TIMEOUT_MS, color: true }, + }, +] diff --git a/test/hosts/jupyter.mjs b/test/hosts/jupyter.mjs new file mode 100644 index 00000000..9e39d459 --- /dev/null +++ b/test/hosts/jupyter.mjs @@ -0,0 +1,44 @@ +// The ACP agent in JupyterLab through Jupyter AI (hosts.yml, PLAN.md M63): +// a new chat with the Muse Spark persona, then a reply, a command allowed +// and one rejected against the fake Muse Code CLI. +// +// node test/hosts/jupyter.mjs + +import process from 'node:process' +import { openBrowser, runCheck, waitForText } from './lib/browser.mjs' + +const [url, shots] = process.argv.slice(2) +if (url === undefined || shots === undefined) { + throw new Error('usage: jupyter.mjs ') +} + +const LAB_TIMEOUT_MS = 90_000 +const { browser, page, shot } = await openBrowser(shots) +await runCheck('jupyterlab: a reply, a command allowed and one rejected', async () => { + await page.goto(`${url}/lab?reset`) + await page.waitForSelector('#jp-main-dock-panel', { timeout: LAB_TIMEOUT_MS }) + await page.locator('.jp-LauncherCard', { hasText: 'Chat' }).first().click() + const chat = page.locator('.jp-MainAreaWidget:visible').last() + const composer = page.locator('textarea:visible').last() + await waitForText(page.mainFrame(), /Muse Spark 1\.3/) + const send = async (text) => { + await composer.fill(text) + await composer.press('Enter') + } + await send('hello from jupyter') + await waitForText(page.mainFrame(), /echo:[\s\S]*hello from jupyter/) + await send('tool: echo allowed-in-jupyter') + await chat + .getByRole('button', { name: /^Allow once/ }) + .first() + .click() + await waitForText(page.mainFrame(), /ran: echo allowed-in-jupyter/) + await send('tool: echo rejected-in-jupyter') + await chat + .getByRole('button', { name: /^Reject/ }) + .last() + .click() + await waitForText(page.mainFrame(), /skipped: echo rejected-in-jupyter/) +}) +await shot('jupyterlab') +await browser.close() diff --git a/test/hosts/jupyter/personas/muse_spark.svg b/test/hosts/jupyter/personas/muse_spark.svg new file mode 100644 index 00000000..2f799a86 --- /dev/null +++ b/test/hosts/jupyter/personas/muse_spark.svg @@ -0,0 +1 @@ +M diff --git a/test/hosts/jupyter/personas/muse_spark_persona.py b/test/hosts/jupyter/personas/muse_spark_persona.py new file mode 100644 index 00000000..e9b31d83 --- /dev/null +++ b/test/hosts/jupyter/personas/muse_spark_persona.py @@ -0,0 +1,25 @@ +# Muse Spark as a Jupyter AI persona through its ACP agent, as docs/acp.md +# gives it; the host check (hosts.yml) names the installed agent and the +# fake Muse Code CLI through MUSE_ACP and MUSE_ACP_ARGS. +import os +import shlex + +from jupyter_ai_acp_client.base_acp_persona import BaseAcpPersona +from jupyter_ai_persona_manager import PersonaDefaults + +AGENT = os.environ.get("MUSE_ACP", "muse-spark-code-acp") +EXTRA_ARGS = shlex.split(os.environ.get("MUSE_ACP_ARGS", "")) + + +class MuseSparkAcpPersona(BaseAcpPersona): + def __init__(self, *args, **kwargs): + super().__init__(*args, executable=[AGENT, *EXTRA_ARGS], **kwargs) + + @property + def defaults(self) -> PersonaDefaults: + return PersonaDefaults( + name="Muse Spark", + description="Muse Spark Code (Unofficial) through its ACP agent.", + avatar_path=os.path.join(os.path.dirname(__file__), "muse_spark.svg"), + system_prompt="unused", + ) diff --git a/test/hosts/jupyter/requirements.txt b/test/hosts/jupyter/requirements.txt new file mode 100644 index 00000000..88b135f8 --- /dev/null +++ b/test/hosts/jupyter/requirements.txt @@ -0,0 +1,5 @@ +# JupyterLab with Jupyter AI's ACP client, for the host check in hosts.yml +# (PLAN.md M63): each release at least seven days old when pinned. +jupyter-ai==3.2.0 +jupyter-ai-persona-manager==0.2.0 +jupyterlab==4.6.3 diff --git a/test/hosts/keystore.sh b/test/hosts/keystore.sh new file mode 100644 index 00000000..fbe165a9 --- /dev/null +++ b/test/hosts/keystore.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# The Model API key's round trip through the operating system's credential +# store (hosts.yml, PLAN.md D61): no key, `auth set` from a pipe, `auth +# status`, `auth clear`, no key again, with the installed +# muse-spark-code-acp. The key is made up; nothing is sent anywhere. On +# Linux run it inside a D-Bus session with an unlocked Secret Service. +# +# sh test/hosts/keystore.sh AGENT +set -u +agent="$1" +fail() { + echo "FAIL $1" >&2 + exit 1 +} +if "$agent" auth status; then fail "a key was stored before the check"; fi +printf 'LLM|123456|made-up-for-the-ci-check\n' | "$agent" auth set || fail "auth set" +"$agent" auth status || fail "auth status after set" +"$agent" auth clear || fail "auth clear" +if "$agent" auth status; then fail "the key outlived auth clear"; fi +echo "ok the key went into the credential store and out again" diff --git a/test/hosts/lib/browser.mjs b/test/hosts/lib/browser.mjs new file mode 100644 index 00000000..cf7affdc --- /dev/null +++ b/test/hosts/lib/browser.mjs @@ -0,0 +1,126 @@ +// Shared by the host checks that drive a browser (hosts.yml): Chrome through +// playwright-core, and the Muse Spark panel's own conversation, the same in +// every host that runs the extension's webview (code-server, Theia). The +// fake Muse Code CLI answers `tool: ` with a gated command and +// anything else with "echo: " (test/e2e/fake-muse/serve.mjs). + +import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { setTimeout as sleep } from 'node:timers/promises' +import { chromium } from 'playwright-core' +import { findChrome } from '../../../scripts/lib/chrome.mjs' + +const POLL_MS = 300 +const TEXT_TIMEOUT_MS = 30_000 +const FRAME_TIMEOUT_MS = 60_000 +const VIEWPORT = { width: 1400, height: 900 } + +/** Chrome as an absolute path (Playwright takes no bare name). */ +function chromePath() { + const found = findChrome() + if (found === undefined) { + throw new Error('No Chrome install found; set CHROME_PATH to the browser executable') + } + if (path.isAbsolute(found)) { + return found + } + try { + return execFileSync('which', [found], { encoding: 'utf8' }).trim() + } catch { + throw new Error(`${found} is not on PATH; set CHROME_PATH to the browser executable`) + } +} + +/** A page in a fresh browser; `shots` names the folder for screenshots. */ +export async function openBrowser(shots) { + mkdirSync(shots, { recursive: true }) + // Root cannot use Chrome's sandbox (the development container); CI runs unprivileged. + const isRoot = process.getuid?.() === 0 + const browser = await chromium.launch({ + executablePath: chromePath(), + args: isRoot ? ['--no-sandbox'] : [], + }) + const page = await browser.newPage({ viewport: VIEWPORT }) + return { + browser, + page, + shot: (name) => page.screenshot({ path: path.join(shots, `${name}.png`) }), + } +} + +async function bodyText(frame) { + return (await frame.locator('body').textContent()) ?? '' +} + +/** Waits until the frame's text matches, and returns it. */ +export async function waitForText(frame, pattern, timeoutMs = TEXT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs + let text = '' + while (Date.now() < deadline) { + text = await bodyText(frame) + if (pattern.test(text)) { + return text + } + await sleep(POLL_MS) + } + throw new Error(`timed out waiting for ${String(pattern)}; the text ended:\n${text.slice(-800)}`) +} + +/** The webview frame whose Muse Spark composer is visible. */ +export async function panelFrame(page, timeoutMs = FRAME_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + for (const frame of page.frames()) { + try { + if (await frame.locator('textarea.composer-input').first().isVisible()) { + return frame + } + } catch { + // A frame that navigates away while it is asked is skipped. + } + } + await page.waitForTimeout(POLL_MS) + } + throw new Error('no visible Muse Spark composer in any frame') +} + +/** + * A reply, a command allowed and one rejected, in the panel: what each + * host must show, as in docs/certification/m62.md. `tag` keeps runs apart. + */ +export async function panelConversation(frame, tag) { + const composer = frame.locator('textarea.composer-input').first() + const send = async (text) => { + await composer.fill(text) + await composer.press('Enter') + } + await send(`hello from ${tag}`) + await waitForText(frame, new RegExp(`echo: hello from ${tag}`)) + await send(`tool: echo allowed-in-${tag}`) + await waitForText(frame, /Allow once/) + await frame + .getByRole('button', { name: /^Allow once/ }) + .first() + .click() + await waitForText(frame, new RegExp(`ran: echo allowed-in-${tag}`)) + await send(`tool: echo rejected-in-${tag}`) + await waitForText(frame, new RegExp(String.raw`rejected-in-${tag}[\s\S]*Reject`)) + await frame + .getByRole('button', { name: /^Reject/ }) + .last() + .click() + await waitForText(frame, new RegExp(`skipped: echo rejected-in-${tag}`)) +} + +/** Runs a check, prints its outcome and sets the exit code. */ +export async function runCheck(name, check) { + try { + await check() + console.log(`ok ${name}`) + } catch (error) { + console.error(`FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/test/hosts/neovim/check.lua b/test/hosts/neovim/check.lua new file mode 100644 index 00000000..7b2f5582 --- /dev/null +++ b/test/hosts/neovim/check.lua @@ -0,0 +1,44 @@ +-- The ACP agent in CodeCompanion, headless (hosts.yml, PLAN.md M63): a reply, +-- then CodeCompanion's approval prompt answered by pressing its keys in the +-- chat buffer: Accept runs the command, Reject skips it. Exits 1 on a miss. +local failures = 0 +local want = "accept" +local approval_prompt = require("codecompanion.interactions.chat.helpers.approval_prompt") +local original = approval_prompt.request +approval_prompt.request = function(chat, opts) + local done = original(chat, opts) + vim.schedule(function() + for _, choice in ipairs(opts.choices) do + if choice.label:lower():find(want) and not choice.preview then + vim.api.nvim_set_current_buf(chat.bufnr) + vim.api.nvim_feedkeys(vim.keycode(choice.keymap), "x", false) + return + end + end + end) + return done +end + +local chat = require("codecompanion").chat({}) +local function text() + return table.concat(vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false), "\n") +end +local function expect(label, prompt, pattern) + chat:add_buf_message({ role = "user", content = prompt }) + chat:submit() + local ok = vim.wait(40000, function() return text():find(pattern) ~= nil end, 100) + vim.wait(1000, function() return false end, 100) + io.stdout:write(string.format("\n%s %s\n", ok and "ok " or "FAIL", label)) + if not ok then failures = failures + 1 end +end + +expect("a reply streams", "hello from neovim", "echo: hello from neovim") +want = "accept" +expect("Accept runs the command", "tool: echo allowed-in-neovim", "ran: echo allowed%-in%-neovim") +want = "reject" +expect("Reject skips it", "tool: echo rejected-in-neovim", "skipped: echo rejected%-in%-neovim") +if failures > 0 then + io.stdout:write("--- the chat buffer ---\n" .. text() .. "\n") + vim.cmd("cquit 1") +end +vim.cmd("qa!") diff --git a/test/hosts/neovim/init.lua b/test/hosts/neovim/init.lua new file mode 100644 index 00000000..f9c46fd9 --- /dev/null +++ b/test/hosts/neovim/init.lua @@ -0,0 +1,20 @@ +-- CodeCompanion with muse-spark-code-acp as its ACP adapter (hosts.yml, +-- PLAN.md M63), as docs/acp.md gives it; the check names the installed agent +-- and the fake Muse Code CLI through MUSE_ACP and FAKE_MUSE. +local pack = os.getenv("NVIM_PACK") +vim.opt.rtp:prepend(pack .. "/plenary.nvim") +vim.opt.rtp:prepend(pack .. "/codecompanion.nvim") +require("codecompanion").setup({ + adapters = { + acp = { + muse_spark = function() + return require("codecompanion.adapters").extend("goose", { + name = "muse_spark", + formatted_name = "Muse Spark", + commands = { default = { os.getenv("MUSE_ACP"), "--muse-binary", os.getenv("FAKE_MUSE") } }, + }) + end, + }, + }, + interactions = { chat = { adapter = "muse_spark" } }, +}) diff --git a/test/hosts/run-code-server.sh b/test/hosts/run-code-server.sh new file mode 100644 index 00000000..01e60462 --- /dev/null +++ b/test/hosts/run-code-server.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# code-server check (hosts.yml, PLAN.md M62): installs VSIX into the given +# code-server with its data, extensions and settings in WORK, points the +# extension at the fake Muse Code CLI, serves on 127.0.0.1 and drives the +# panel (code-server.mjs). Screenshots and logs stay in WORK. +# +# sh test/hosts/run-code-server.sh CODE_SERVER_BIN VSIX WORK +set -eu +cs="$1" +vsix="$2" +work="$3" +port="${HOSTS_PORT:-8123}" +here="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$work/data/User" "$work/extensions" "$work/workspace" +muse="$(sh "$here/fake-muse.sh" "$work/fake-muse")" +printf 'hello\n' > "$work/workspace/notes.txt" +printf '{\n "museSpark.museBinaryPath": "%s",\n "workbench.startupEditor": "none",\n "security.workspace.trust.enabled": false\n}\n' "$muse" \ + > "$work/data/User/settings.json" +printf 'bind-addr: 127.0.0.1:%s\nauth: none\ncert: false\n' "$port" > "$work/config.yaml" +set -- --config "$work/config.yaml" --user-data-dir "$work/data" --extensions-dir "$work/extensions" +"$cs" "$@" --install-extension "$vsix" +XDG_CONFIG_HOME="$work/fake-muse/config" "$cs" "$@" --disable-telemetry --disable-update-check \ + "$work/workspace" > "$work/code-server.log" 2>&1 & +server=$! +trap 'kill "$server" 2>/dev/null || true' EXIT +tries=0 +until curl -s -o /dev/null "http://127.0.0.1:$port/"; do + tries=$((tries + 1)) + if [ "$tries" -gt 120 ]; then + echo "code-server did not start; its log:" >&2 + cat "$work/code-server.log" >&2 + exit 1 + fi + sleep 0.5 +done +"$cs" --version | head -1 +node "$here/code-server.mjs" "http://127.0.0.1:$port" "$work/workspace" "$work/shots" +grep -rh "Activating Muse Spark" "$work/data/logs" || true diff --git a/test/hosts/run-emacs.sh b/test/hosts/run-emacs.sh new file mode 100644 index 00000000..016cf4ff --- /dev/null +++ b/test/hosts/run-emacs.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Emacs check (hosts.yml, PLAN.md M63): acp.el, shell-maker and agent-shell +# at the tags below (each at least seven days old when pinned), cloned into +# WORK, then acp-check.el and agent-shell-check.el in batch against AGENT +# (the installed muse-spark-code-acp) and the fake Muse Code CLI. HOME is +# WORK's, so no Emacs configuration is read. +# +# sh test/hosts/run-emacs.sh AGENT WORK +set -eu +agent="$1" +work="$2" +here="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$work/lisp" "$work/home" "$work/workspace" +for pin in acp.el@v0.15.1 shell-maker@v0.97.3 agent-shell@v0.77.4; do + repo="${pin%@*}" + git clone --quiet --depth 1 --branch "${pin#*@}" "https://github.com/xenodium/$repo" "$work/src/$repo" + cp "$work/src/$repo"/*.el "$work/lisp/" +done +muse="$(sh "$here/fake-muse.sh" "$work/fake-muse")" +emacs --version | head -1 +for check in acp-check agent-shell-check; do + (cd "$work/workspace" && HOME="$work/home" XDG_CONFIG_HOME="$work/fake-muse/config" \ + ACP_EL_DIR="$work/lisp" MUSE_ACP="$agent" FAKE_MUSE="$muse" WORKSPACE="$work/workspace" \ + emacs --batch -l "$here/emacs/$check.el") +done diff --git a/test/hosts/run-fork.sh b/test/hosts/run-fork.sh new file mode 100644 index 00000000..99398625 --- /dev/null +++ b/test/hosts/run-fork.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# A VS Code fork's check (forks.yml, PLAN.md M62b): the fork's latest Linux +# x64 release (fork-release.mjs), unpacked into WORK without installing it; +# its version and VS Code base printed; the VSIX installed with the fork's +# own CLI and listed; then the extension's integration tests in the fork. +# FORK_URL, when set, is the package to test instead of the latest (an +# AppImage, a .deb or a tar archive), with FORK only naming the run. Needs +# the dev build (`npm run build:dev`) and a display (xvfb-run on Linux). +# +# sh test/hosts/run-fork.sh cursor|devin-desktop|kiro|positron VSIX WORK +set -eu +fork="$1" +vsix="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +work="$3" +here="$(cd "$(dirname "$0")" && pwd)" +url="${FORK_URL:-}" +if [ -z "$url" ]; then + release="$(node "$here/fork-release.mjs" latest "$fork")" + url="${release#* }" + echo "$fork ${release%% *}: $url" +fi +rm -rf "$work/app" +mkdir -p "$work/app" "$work/data" "$work/extensions" +curl -fsSL -o "$work/package" "$url" +case "$url" in + *.AppImage) + chmod +x "$work/package" + (cd "$work/app" && "$work/package" --appimage-extract > /dev/null) + ;; + *.deb) dpkg-deb -x "$work/package" "$work/app" ;; + *.tar.gz | *.tgz | *.tar | *.tar.xz) tar -xf "$work/package" -C "$work/app" ;; + *) + echo "FAIL $fork: no unpacker for $url" >&2 + exit 1 + ;; +esac +product="$(find "$work/app" -path '*/resources/app/product.json' | head -n 1)" +if [ -z "$product" ]; then + echo "FAIL $fork: no resources/app/product.json in the package" >&2 + exit 1 +fi +root="${product%/resources/app/product.json}" +described="$(node "$here/fork-release.mjs" describe "$root")" +name="$(printf '%s\n' "$described" | head -n 1)" +printf '%s\n' "$described" | tail -n 1 +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf -- '- %s\n' "$(printf '%s\n' "$described" | tail -n 1)" >> "$GITHUB_STEP_SUMMARY" +fi +set -- --user-data-dir "$work/data" --extensions-dir "$work/extensions" +"$root/bin/$name" "$@" --install-extension "$vsix" +"$root/bin/$name" "$@" --list-extensions --show-versions > "$work/extensions.txt" +if ! grep -qi '^randynorthrup\.muse-spark-code@' "$work/extensions.txt"; then + echo "FAIL $fork: the VSIX is not among the installed extensions:" >&2 + cat "$work/extensions.txt" >&2 + exit 1 +fi +echo "ok $fork: $(grep -i '^randynorthrup\.muse-spark-code@' "$work/extensions.txt") installed" +cd "$here/../.." +HOST_BIN="$root/$name" HOST_LABEL="$fork" npx vscode-test --config test/hosts/installed.vscode-test.mjs diff --git a/test/hosts/run-jupyter.sh b/test/hosts/run-jupyter.sh new file mode 100644 index 00000000..a2831bb8 --- /dev/null +++ b/test/hosts/run-jupyter.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# JupyterLab check (hosts.yml, PLAN.md M63): a venv in WORK with the pinned +# JupyterLab and Jupyter AI, the Muse Spark persona in the served folder, +# the agent AGENT (the installed muse-spark-code-acp) on the fake Muse Code +# CLI, and the chat driven by jupyter.mjs. Jupyter's own folders are WORK's. +# +# sh test/hosts/run-jupyter.sh AGENT WORK +set -eu +agent="$1" +work="$2" +port="${HOSTS_PORT:-8899}" +here="$(cd "$(dirname "$0")" && pwd)" +python3 -m venv "$work/venv" +"$work/venv/bin/pip" install --quiet --disable-pip-version-check -r "$here/jupyter/requirements.txt" +mkdir -p "$work/served/.jupyter/personas" "$work/home" +cp "$here/jupyter/personas/"* "$work/served/.jupyter/personas/" +muse="$(sh "$here/fake-muse.sh" "$work/fake-muse")" +# Root (the development container) must say so; CI runs unprivileged. +root_flag="" +if [ "$(id -u)" = 0 ]; then root_flag="--allow-root"; fi +( + cd "$work/served" + HOME="$work/home" JUPYTER_CONFIG_DIR="$work/home/config" JUPYTER_DATA_DIR="$work/home/data" \ + JUPYTER_RUNTIME_DIR="$work/home/runtime" XDG_CONFIG_HOME="$work/fake-muse/config" \ + MUSE_ACP="$agent" MUSE_ACP_ARGS="--muse-binary $muse" \ + exec "$work/venv/bin/jupyter" lab $root_flag --no-browser --ip 127.0.0.1 --port "$port" \ + --ServerApp.token= --ServerApp.password= --ServerApp.root_dir="$work/served" +) > "$work/jupyter.log" 2>&1 & +server=$! +trap 'kill "$server" 2>/dev/null || true' EXIT +tries=0 +until grep -q "is running at" "$work/jupyter.log"; do + tries=$((tries + 1)) + if [ "$tries" -gt 240 ]; then + echo "JupyterLab did not start; its log:" >&2 + cat "$work/jupyter.log" >&2 + exit 1 + fi + sleep 0.5 +done +"$work/venv/bin/pip" show jupyterlab jupyter-ai | grep -E '^(Name|Version)' +node "$here/jupyter.mjs" "http://127.0.0.1:$port" "$work/shots" +# Jupyter AI's notebook tools reach the agent (M63c). +grep "MCP servers from the editor: Jupyter MCP Server" "$work/jupyter.log" diff --git a/test/hosts/run-neovim.sh b/test/hosts/run-neovim.sh new file mode 100644 index 00000000..d5e6e355 --- /dev/null +++ b/test/hosts/run-neovim.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Neovim check (hosts.yml, PLAN.md M63): Neovim's Linux release, plenary.nvim +# and CodeCompanion at the pins below (each at least seven days old when +# pinned) in WORK, then check.lua headless against AGENT (the installed +# muse-spark-code-acp) and the fake Muse Code CLI. HOME and Neovim's own +# folders are WORK's, and -u names the configuration. +# +# sh test/hosts/run-neovim.sh AGENT WORK +set -eu +agent="$1" +work="$2" +here="$(cd "$(dirname "$0")" && pwd)" +nvim_version="v0.11.4" +codecompanion_tag="v19.25.0" +plenary_commit="74b06c6c75e4eeb3108ec01852001636d85a932b" +mkdir -p "$work/pack" "$work/home" "$work/workspace" +curl -fsSL -o "$work/nvim.tar.gz" \ + "https://github.com/neovim/neovim/releases/download/$nvim_version/nvim-linux-x86_64.tar.gz" +tar -xzf "$work/nvim.tar.gz" -C "$work" +git clone --quiet --depth 1 --branch "$codecompanion_tag" \ + https://github.com/olimorris/codecompanion.nvim "$work/pack/codecompanion.nvim" +git clone --quiet https://github.com/nvim-lua/plenary.nvim "$work/pack/plenary.nvim" +git -C "$work/pack/plenary.nvim" checkout --quiet "$plenary_commit" +muse="$(sh "$here/fake-muse.sh" "$work/fake-muse")" +"$work/nvim-linux-x86_64/bin/nvim" --version | head -1 +cd "$work/workspace" +HOME="$work/home" XDG_DATA_HOME="$work/home/data" XDG_STATE_HOME="$work/home/state" \ + XDG_CACHE_HOME="$work/home/cache" XDG_CONFIG_HOME="$work/fake-muse/config" \ + NVIM_PACK="$work/pack" MUSE_ACP="$agent" FAKE_MUSE="$muse" \ + "$work/nvim-linux-x86_64/bin/nvim" --headless -u "$here/neovim/init.lua" \ + -c "luafile $here/neovim/check.lua" diff --git a/test/hosts/run-theia.sh b/test/hosts/run-theia.sh new file mode 100644 index 00000000..049f98d0 --- /dev/null +++ b/test/hosts/run-theia.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Theia check (hosts.yml, PLAN.md M62): builds the browser app of +# test/hosts/theia in WORK, unpacks VSIX as its plugin, points the +# extension at the fake Muse Code CLI and drives it (theia.mjs). Theia's +# configuration folder is WORK's too. +# +# sh test/hosts/run-theia.sh VSIX WORK +set -eu +vsix="$1" +work="$2" +port="${HOSTS_PORT:-3030}" +here="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$work/app" "$work/config" "$work/workspace" +cp "$here/theia/package.json" "$work/app/package.json" +(cd "$work/app" && npm install --no-audit --no-fund --loglevel=error && npx theia build --mode development) +rm -rf "$work/app/plugins" && mkdir -p "$work/app/plugins/muse-spark-code" +unzip -q "$vsix" 'extension/*' -d "$work/unpacked" +cp -R "$work/unpacked/extension/." "$work/app/plugins/muse-spark-code/" +muse="$(sh "$here/fake-muse.sh" "$work/fake-muse")" +printf 'hello\n' > "$work/workspace/notes.txt" +printf '{\n "museSpark.museBinaryPath": "%s",\n "security.workspace.trust.enabled": false\n}\n' "$muse" \ + > "$work/config/settings.json" +(cd "$work/app" && THEIA_CONFIG_DIR="$work/config" XDG_CONFIG_HOME="$work/fake-muse/config" \ + npx theia start --hostname 127.0.0.1 --port "$port" --plugins=local-dir:plugins "$work/workspace") \ + > "$work/theia.log" 2>&1 & +server=$! +trap 'kill "$server" 2>/dev/null || true; pkill -f "$work/app" 2>/dev/null || true' EXIT +tries=0 +until grep -q "Theia app listening" "$work/theia.log"; do + tries=$((tries + 1)) + if [ "$tries" -gt 240 ]; then + echo "Theia did not start; its log:" >&2 + cat "$work/theia.log" >&2 + exit 1 + fi + sleep 0.5 +done +grep -o '"version": "[^"]*"' "$work/app/node_modules/@theia/core/package.json" | head -1 +node "$here/theia.mjs" "http://localhost:$port" "$work/workspace" "$work/shots" diff --git a/test/hosts/run-vscodium.sh b/test/hosts/run-vscodium.sh new file mode 100644 index 00000000..296bb1a9 --- /dev/null +++ b/test/hosts/run-vscodium.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# VSCodium check (hosts.yml, PLAN.md M62): the extension's integration tests +# in VSCodium RELEASE (a tag such as 1.99.32846, or `latest`, read from +# VSCodium's own version feed), downloaded into WORK. Needs the dev build +# (`npm run build:dev`) and a display (xvfb-run on Linux). +# +# sh test/hosts/run-vscodium.sh RELEASE WORK +set -eu +release="$1" +work="$2" +here="$(cd "$(dirname "$0")" && pwd)" +if [ "$release" = latest ]; then + release="$(curl -fsSL https://raw.githubusercontent.com/VSCodium/versions/master/stable/linux/x64/latest.json \ + | node -e 'let s="";process.stdin.on("data",(c)=>{s+=c}).on("end",()=>{process.stdout.write(JSON.parse(s).name)})')" +fi +mkdir -p "$work/vscodium" +curl -fsSL -o "$work/vscodium.tar.gz" \ + "https://github.com/VSCodium/vscodium/releases/download/$release/VSCodium-linux-x64-$release.tar.gz" +tar -xzf "$work/vscodium.tar.gz" -C "$work/vscodium" +echo "VSCodium $release" +cd "$here/../.." +HOST_BIN="$work/vscodium/codium" HOST_LABEL=vscodium npx vscode-test --config test/hosts/installed.vscode-test.mjs diff --git a/test/hosts/theia.mjs b/test/hosts/theia.mjs new file mode 100644 index 00000000..821c91fb --- /dev/null +++ b/test/hosts/theia.mjs @@ -0,0 +1,55 @@ +// The extension in Eclipse Theia (hosts.yml, PLAN.md M62): the sidebar +// opened with Ctrl+Esc (Theia 1.75 fires no `onView:` for a webview view, +// so the view alone does not start the extension; see +// docs/certification/m62.md) and the panel in an editor tab, each with a +// reply, a command allowed and one rejected against the fake CLI. Theia +// serves webviews from `.webview.`, so the URL must use +// `localhost`, which Chrome resolves with any subdomain. +// +// node test/hosts/theia.mjs + +import process from 'node:process' +import { openBrowser, panelConversation, panelFrame, runCheck } from './lib/browser.mjs' + +const [url, workspace, shots] = process.argv.slice(2) +if (url === undefined || workspace === undefined || shots === undefined) { + throw new Error('usage: theia.mjs ') +} + +const SHELL_TIMEOUT_MS = 90_000 +const SETTLE_MS = 5000 +// The middle of the editor area at the check's 1400 × 900 viewport. +const EDITOR_AREA = { x: 700, y: 450 } + +async function openTheia(name) { + const opened = await openBrowser(shots) + await opened.page.goto(`${url}/#${workspace}`) + await opened.page.waitForSelector('#theia-app-shell', { timeout: SHELL_TIMEOUT_MS }) + await opened.page.waitForTimeout(SETTLE_MS) + return { ...opened, shot: () => opened.shot(`theia-${name}`) } +} + +const sidebar = await openTheia('sidebar') +await runCheck('theia: the sidebar, started with Ctrl+Esc', async () => { + await sidebar.page.mouse.click(EDITOR_AREA.x, EDITOR_AREA.y) + await sidebar.page.keyboard.press('Control+Escape') + await panelConversation(await panelFrame(sidebar.page), 'theia-sidebar') +}) +await sidebar.shot() +await sidebar.browser.close() + +const tab = await openTheia('tab') +await runCheck('theia: the panel in an editor tab', async () => { + await tab.page.mouse.click(EDITOR_AREA.x, EDITOR_AREA.y) + await tab.page.keyboard.press('F1') + const palette = tab.page.locator('.quick-input-widget input').first() + await palette.waitFor() + await palette.fill('>Muse Spark: Open in New Tab') + await tab.page + .locator('.quick-input-list .monaco-list-row', { hasText: 'Open in New Tab' }) + .first() + .click() + await panelConversation(await panelFrame(tab.page), 'theia-tab') +}) +await tab.shot() +await tab.browser.close() diff --git a/test/hosts/theia/package.json b/test/hosts/theia/package.json new file mode 100644 index 00000000..3145a984 --- /dev/null +++ b/test/hosts/theia/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "name": "muse-spark-theia-check", + "version": "0.0.0", + "description": "A browser Theia with VS Code extension support, for the host check in hosts.yml (PLAN.md M62)", + "theia": { + "target": "browser", + "frontend": { + "config": { + "applicationName": "Muse Spark host check" + } + } + }, + "dependencies": { + "@theia/core": "1.75.0", + "@theia/editor": "1.75.0", + "@theia/filesystem": "1.75.0", + "@theia/markers": "1.75.0", + "@theia/messages": "1.75.0", + "@theia/monaco": "1.75.0", + "@theia/navigator": "1.75.0", + "@theia/output": "1.75.0", + "@theia/plugin-ext": "1.75.0", + "@theia/plugin-ext-vscode": "1.75.0", + "@theia/preferences": "1.75.0", + "@theia/process": "1.75.0", + "@theia/terminal": "1.75.0", + "@theia/workspace": "1.75.0" + }, + "devDependencies": { + "@theia/cli": "1.75.0" + } +} diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index e33ea9d9..287fbaf9 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -3,7 +3,7 @@ // Host with mocha's TDD interface (suite/test globals). "extends": "../../tsconfig.base.json", "compilerOptions": { - "lib": ["ES2024"], + "lib": ["ES2023"], "types": ["node", "vscode", "mocha"] }, "include": ["./**/*.ts", "../../src/shared/**/*.ts"] diff --git a/test/unit/MuseCodeHost.test.ts b/test/unit/MuseCodeHost.test.ts index 84bfcb99..8e33cc68 100644 --- a/test/unit/MuseCodeHost.test.ts +++ b/test/unit/MuseCodeHost.test.ts @@ -696,11 +696,24 @@ describe('MuseCodeHost: stored sessions (M6)', () => { ) const loaded = await host.resumeSession('old', 'muse-spark-1.3', { ide: { url: 'http://127.0.0.1:1/mcp', headers: { Authorization: 'Bearer x' } }, + // An ACP client's stdio server (M63c). + notes: { command: 'notes-mcp', args: ['--stdio'], env: { NOTES_DIR: '/ws/notes' } }, }) expect(server.requestsFor('session/resume')[0]?.params).toMatchObject({ sessionId: 'old', history: 'snapshot', - config: { mcpServers: { ide: { transport: 'streamableHttp', mode: 'optional' } } }, + config: { + mcpServers: { + ide: { transport: 'streamableHttp', mode: 'optional' }, + notes: { + transport: 'stdio', + command: 'notes-mcp', + args: ['--stdio'], + env: { NOTES_DIR: '/ws/notes' }, + mode: 'optional', + }, + }, + }, }) expect(loaded.session.sessionId).toBe('old') expect(loaded.session.modelId).toBe('muse-spark-1.3') diff --git a/test/unit/acpAgent.test.ts b/test/unit/acpAgent.test.ts new file mode 100644 index 00000000..5035484f --- /dev/null +++ b/test/unit/acpAgent.test.ts @@ -0,0 +1,2037 @@ +import * as acp from '@agentclientprotocol/sdk' +import { MspError } from '@muse-code/sdk' +import { describe, expect, it, vi } from 'vitest' +import { type AcpAgentDeps, type BackendReadiness, createAcpAgent } from '../../src/acp/agent' +import { AcpPaidUse } from '../../src/acp/paid' +import type { AgentHost, AgentSession, ModelSummary } from '../../src/core/agent/agentBackend' +import type { + AgentEvent, + ApprovalChoice, + ItemSnapshot, + Question, +} from '../../src/shared/agentEvents' +import { type AcpPaidFeature, UI_TEXT } from '../../src/shared/constants' +import type { PaidUseRequest } from '../../src/shared/paid' +import { approvalModeFor } from '../../src/shared/permissionModes' +import { FAKE_MODELS, FakeAgentHost, type FakeAgentSession } from './helpers/fakeAgent' +import { memoryPaidGrants } from './helpers/paidGrants' +import { acpMspHost, acpResumeEnvelope, answerMsp } from './helpers/acpMsp' + +// M63 (PLAN.md D62): the agent driven by the ACP SDK's own client, in +// process, against a scripted backend. + +const CWD = process.platform === 'win32' ? String.raw`C:\work\app` : '/work/app' +const POLL_MS = 5 +const WAIT_MS = 2000 + +const CHOICES: ApprovalChoice[] = [ + { choiceId: 'allow_once', label: 'Allow once', decision: 'approved', scope: 'once' }, + { + choiceId: 'allow_session', + label: 'Allow for the session', + decision: 'approvedPolicyAmendment', + scope: 'session', + }, + { choiceId: 'abort', label: 'Reject', decision: 'abort', scope: 'once' }, +] + +type PermissionAnswer = ( + request: acp.RequestPermissionRequest, +) => acp.RequestPermissionResponse | Promise + +interface Harness { + readonly host: FakeAgentHost + /** What each readiness question asked: a recheck (authenticate) or not. */ + readonly rechecks: boolean[] + readonly paid: AcpPaidUse + readonly grants: ReturnType + readonly updates: acp.SessionUpdate[] + readonly permissions: acp.RequestPermissionRequest[] + readonly elicitations: acp.CreateElicitationRequest[] + readonly log: { readonly info: ReturnType; readonly warn: ReturnType } + run(op: (client: acp.ClientContext) => Promise): Promise +} + +interface HarnessOptions { + readonly backendHost?: AgentHost + readonly readiness?: BackendReadiness + readonly answer?: PermissionAnswer + /** The client's form answer, or a function answering when the test lets it. */ + readonly elicitation?: + acp.CreateElicitationResponse | (() => Promise) + /** The client's form request fails instead of answering. */ + readonly isElicitationBroken?: boolean + readonly canBypass?: boolean + readonly allowsContributorModels?: boolean + readonly kind?: 'museCode' | 'modelApi' + readonly paid?: readonly AcpPaidFeature[] + /** `--trust-workspace`: "Allow always" is offered and kept (M58). */ + readonly isTrusted?: boolean +} + +function harness(options: HarnessOptions = {}): Harness { + const host = new FakeAgentHost() + const kind = options.kind ?? 'museCode' + host.info = { ...host.info, kind } + const updates: acp.SessionUpdate[] = [] + const permissions: acp.RequestPermissionRequest[] = [] + const elicitations: acp.CreateElicitationRequest[] = [] + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + const grants = memoryPaidGrants() + const rechecks: boolean[] = [] + const paid = new AcpPaidUse({ + flagged: options.paid ?? [], + canRemember: () => options.isTrusted === true, + grants, + log, + }) + const deps: AcpAgentDeps = { + backend: { + kind, + readiness: (isRecheck) => { + rechecks.push(isRecheck) + return Promise.resolve(options.readiness ?? { state: 'ready' }) + }, + hostFor: () => Promise.resolve(options.backendHost ?? host), + }, + version: '0.0.0-test', + options: { + canBypass: options.canBypass ?? false, + allowsContributorModels: options.allowsContributorModels ?? false, + initialMode: 'manual', + }, + signIn: { + id: 'muse-code-login', + name: 'Sign in', + description: 'Sign in to Muse Code', + args: ['login'], + command: 'muse-spark-code-acp login', + }, + defaultCwd: CWD, + paid, + log, + } + const agent = createAcpAgent(deps) + const client = acp + .client({ name: 'test-client' }) + .onNotification('session/update', (context) => { + updates.push(context.params.update) + }) + .onRequest('session/request_permission', async (context) => { + permissions.push(context.params) + return await (options.answer ?? (() => ({ outcome: { outcome: 'cancelled' } })))( + context.params, + ) + }) + .onRequest('elicitation/create', (context) => { + elicitations.push(context.params) + if (options.isElicitationBroken === true) { + throw new Error('the form could not be shown') + } + return typeof options.elicitation === 'function' + ? options.elicitation() + : (options.elicitation ?? { action: 'cancel' }) + }) + return { + host, + rechecks, + paid, + grants, + updates, + permissions, + elicitations, + log, + run: (op) => client.connectWith(agent, op), + } +} + +/** Lets the agent act on what it just received, before a test checks it did nothing. */ +async function settled(): Promise { + await new Promise((resolve) => setTimeout(resolve, WAIT_MS / 10)) +} + +/** Observe overlapping requests immediately, so an expected rejection is never unhandled. */ +async function didRequestSucceed( + request: Promise, + finished?: { isDone: boolean }, +): Promise { + try { + await request + return true + } catch { + return false + } finally { + if (finished !== undefined) { + finished.isDone = true + } + } +} + +/** The same held form, answered only after a lifecycle boundary. */ +function delayedForm() { + const form = Promise.withResolvers() + return { form, h: harness({ elicitation: () => form.promise }) } +} + +/** A trusted paid search question whose late answer must never become a grant. */ +function delayedPaidSearch() { + const answer = Promise.withResolvers() + return { + answer, + h: harness({ + kind: 'modelApi', + paid: ['webSearch'], + isTrusted: true, + answer: () => answer.promise, + }), + } +} + +function colourQuestion(): Extract { + return { + type: 'questionRequested', + userInputId: 'input-1', + itemId: 'q1', + questions: [ + { + id: 'color', + header: 'Colour', + question: 'Which colour?', + selection: { mode: 'single' }, + options: [{ label: 'Blue' }, { label: 'Red' }], + }, + ], + } +} + +function loadStoredSession(client: acp.ClientContext) { + return client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) +} + +/** Owns one real MSP fixture and client, closing the transport even after a failed assertion. */ +async function withMspSession( + op: (client: acp.ClientContext, wire: ReturnType) => Promise, +): Promise { + const wire = acpMspHost() + const h = harness({ backendHost: wire.host }) + try { + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await loadStoredSession(client) + await op(client, wire) + }) + } finally { + await wire.host.close() + } +} + +/** Starts a prompt whose turn/start reply is explicitly released by the test. */ +async function pendingMspPrompt(client: acp.ClientContext, wire: ReturnType) { + wire.server.silence('turn/start') + const response = prompt(client, 'old-1') + await until(() => wire.server.requestsFor('turn/start').length === 1) + return { response } +} + +/** Hands a held session back retained on the next resume, as both hosts do (Grok on 78a74430). */ +function retainOnResume(h: Harness, shared: FakeAgentSession): void { + const resume = h.host.resumeSession.getMockImplementation()! + h.host.resumeSession.mockImplementation(async (...args) => ({ + ...(await resume(...args)), + session: shared, + })) +} + +async function until(isMet: () => boolean): Promise { + const deadline = Date.now() + WAIT_MS + while (!isMet()) { + if (Date.now() > deadline) { + throw new Error('condition not met in time') + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)) + } +} + +async function start( + client: acp.ClientContext, + capabilities: acp.ClientCapabilities = {}, +): Promise { + await client.request('initialize', { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: capabilities, + }) + return await client.request('session/new', { cwd: CWD, mcpServers: [] }) +} + +async function startFormSession(h: Harness, client: acp.ClientContext) { + const { sessionId } = await start(client, { elicitation: { form: {} } }) + return { sessionId, session: h.host.sessions[0]! } +} + +function prompt(client: acp.ClientContext, sessionId: string, text = 'hello') { + return client.request('session/prompt', { sessionId, prompt: [{ type: 'text', text }] }) +} + +function message(itemId: string, text: string, status = 'inProgress'): ItemSnapshot { + return { itemId, kind: 'agentMessage', status, turnId: 'turn-1', text } +} + +function approval(overrides: Partial> = {}) { + return { + type: 'approvalRequested' as const, + approvalId: 'approval-1', + itemId: 'tool-1', + toolName: 'powershell', + rawArgs: JSON.stringify({ command: 'npm test' }), + requirementId: { approvalId: 'approval-1', sourceIndex: 0 }, + subject: { kind: 'command', command: 'npm test' }, + availableChoices: CHOICES, + isJudgeEscalated: false, + isProtectedWrite: false, + ...overrides, + } +} + +/** Asks one question in a session whose client has forms, until it is answered or declined. */ +async function askInForm(h: Harness, question: Question): Promise { + await h.run(async (client) => { + const { sessionId } = await start(client, { elicitation: { form: {} } }) + await turn(h, client, sessionId, async (session) => { + session.emit({ + type: 'questionRequested', + userInputId: 'input-1', + itemId: 'q1', + questions: [question], + }) + await until( + () => + session.answerQuestions.mock.calls.length + session.cancelQuestions.mock.calls.length === + 1, + ) + }) + }) +} + +/** Loads `old-1` on a fresh connection; its backend session. */ +async function loadOld(h: Harness, client: acp.ClientContext): Promise { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + return h.host.sessions.at(-1)! +} + +/** The next session resumed, as `change` leaves it before the agent has it. */ +function onNextResume(h: Harness, change: (session: AgentSession) => void): void { + const resume = h.host.resumeSession.getMockImplementation()! + h.host.resumeSession.mockImplementationOnce(async (...args) => { + const loaded = await resume(...args) + change(loaded.session) + return loaded + }) +} + +/** A load whose resumed session `spoil` breaks first: it fails, and that session is let go. */ +async function failedLoad( + h: Harness, + spoil: (session: AgentSession) => void, + after: (client: acp.ClientContext) => Promise = () => Promise.resolve(), +): Promise { + onNextResume(h, spoil) + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await expect( + client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }), + ).rejects.toThrow() + await after(client) + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) +} + +/** Starts a session and a prompt, and waits until the backend has the turn. */ +async function running(h: Harness, client: acp.ClientContext) { + const { sessionId } = await start(client) + const session = h.host.sessions.at(-1)! + const response = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length > 0) + return { sessionId, session, response } +} + +/** Completes the currently scripted turn after any requested cancellation reached the backend. */ +async function finishRunningPrompt( + client: acp.ClientContext, + active: Awaited>, + terminal: string, +): Promise { + if (terminal === 'cancelled') { + await client.notify('session/cancel', { sessionId: active.sessionId }) + await until(() => active.session.cancel.mock.calls.length === 1) + } + active.session.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal }) + await active.response +} + +/** One turn in which the backend asks `approval()`, waits for the decision, then plays `after`. */ +async function approvalTurn( + h: Harness, + after: (session: FakeAgentSession) => Promise = () => Promise.resolve(), +): Promise { + await h.run(async (client) => { + const { sessionId } = await start(client) + await turn(h, client, sessionId, async (session) => { + session.emit(approval()) + await until(() => session.decideApproval.mock.calls.length === 1) + await after(session) + }) + }) +} + +/** Runs one prompt: waits for the turn, plays `events`, completes it with `terminal`. */ +async function turn( + h: Harness, + client: acp.ClientContext, + sessionId: string, + events: (session: FakeAgentSession) => Promise | void, + terminal = 'completed', +) { + const session = h.host.sessions.at(-1) + if (session === undefined) { + throw new Error('no session') + } + const calls = session.sendTurn.mock.calls.length + const response = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length > calls) + await events(session) + session.emit({ type: 'turnCompleted', turnId: `turn-${String(calls + 1)}`, terminal }) + return await response +} + +describe('the ACP agent (M63)', () => { + it('initializes with its capabilities, and a terminal sign-in only for a client that runs one', async () => { + const h = harness() + const [plain, terminal] = await h.run(async (client) => [ + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }), + await client.request('initialize', { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { auth: { terminal: true } }, + }), + ]) + expect(plain.agentCapabilities).toMatchObject({ + loadSession: true, + promptCapabilities: { image: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }) + expect(plain.agentInfo).toMatchObject({ name: 'muse-spark-code-acp', version: '0.0.0-test' }) + expect(plain.authMethods).toEqual([ + { + id: 'muse-code-login', + name: 'Sign in', + description: 'Run “muse-spark-code-acp login” in a terminal, then try again.', + }, + ]) + expect(terminal.authMethods).toEqual([ + { + type: 'terminal', + id: 'muse-code-login', + name: 'Sign in', + description: 'Sign in to Muse Code', + args: ['login'], + }, + ]) + }) + + it('passes the editor’s MCP servers to Muse Code, logging their names only', async () => { + const h = harness() + const servers: acp.McpServer[] = [ + { + name: 'notes', + command: 'notes-mcp', + args: ['--stdio'], + env: [{ name: 'NOTES_DIR', value: '/n' }], + }, + { + type: 'http', + name: 'jupyter', + url: 'http://127.0.0.1:8888/mcp', + headers: [{ name: 'Authorization', value: 'token secret' }], + }, + { type: 'sse', name: 'legacy', url: 'http://127.0.0.1:1/sse', headers: [] }, + ] + await h.run(async (client) => { + const init = await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + expect(init.agentCapabilities?.mcpCapabilities).toEqual({ http: true, sse: false }) + await client.request('session/new', { cwd: CWD, mcpServers: servers }) + await client.request('session/resume', { sessionId: 'old-1', cwd: CWD, mcpServers: servers }) + }) + const forwarded = { + notes: { command: 'notes-mcp', args: ['--stdio'], env: { NOTES_DIR: '/n' } }, + jupyter: { url: 'http://127.0.0.1:8888/mcp', headers: { Authorization: 'token secret' } }, + } + expect(h.host.startSession).toHaveBeenCalledWith( + expect.objectContaining({ mcpServers: forwarded }), + ) + expect(h.host.resumeSession).toHaveBeenCalledWith('old-1', expect.any(String), forwarded) + const logged = JSON.stringify([h.log.info.mock.calls, h.log.warn.mock.calls]) + expect(logged).toContain('legacy') + expect(logged).not.toContain('token secret') + expect(logged).not.toContain('/n') + }) + + it('passes no MCP servers without the grant, or on the Model API backend', async () => { + const servers: acp.McpServer[] = [{ name: 'notes', command: 'notes-mcp', args: [], env: [] }] + const ungranted = harness() + ungranted.host.info = { ...ungranted.host.info, grantedCapabilities: [] } + const modelApi = harness({ kind: 'modelApi' }) + for (const h of [ungranted, modelApi]) { + const capabilities = await h.run(async (client) => { + const init = await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await client.request('session/new', { cwd: CWD, mcpServers: servers }) + return init.agentCapabilities?.mcpCapabilities + }) + expect(h.host.startSession).toHaveBeenCalledWith( + expect.not.objectContaining({ mcpServers: expect.anything() }), + ) + expect(h.log.warn).toHaveBeenCalledWith(expect.stringContaining('not passed on (notes)')) + expect(capabilities?.http).toBe(h === ungranted) + } + }) + + it('answers auth_required until the backend is signed in, and an error when it cannot run', async () => { + const signedOut = harness({ readiness: { state: 'signedOut', message: 'sign in first' } }) + await expect(signedOut.run((client) => start(client))).rejects.toMatchObject({ + code: -32_000, + }) + await expect( + signedOut.run((client) => client.request('authenticate', { methodId: 'x' })), + ).rejects.toMatchObject({ code: -32_000 }) + const missing = harness({ readiness: { state: 'unavailable', message: 'no CLI' } }) + await expect(missing.run((client) => start(client))).rejects.toMatchObject({ code: -32_603 }) + const ready = harness() + expect(await ready.run((client) => client.request('authenticate', { methodId: 'x' }))).toEqual( + {}, + ) + await ready.run((client) => start(client)) + // Only authenticate, after a sign-in in the terminal, asks afresh (PR #49). + expect(ready.rechecks).toEqual([true, false]) + }) + + it('starts a session on the default model with the modes and the config options', async () => { + const h = harness() + const created = await h.run((client) => start(client)) + const session = h.host.sessions[0] + expect(h.host.startSession).toHaveBeenCalledWith({ + workspaceRoot: CWD, + modelId: 'muse-spark-1.3', + approvalMode: 'promptUnmatched', + }) + expect(session?.setReasoningEffort).toHaveBeenCalledWith('high') + expect(created.modes?.currentModeId).toBe('manual') + expect(created.modes?.availableModes.map((mode) => mode.id)).toEqual([ + 'manual', + 'acceptEdits', + 'plan', + 'auto', + ]) + const [model, effort] = created.configOptions ?? [] + expect(model).toMatchObject({ id: 'model', type: 'select', currentValue: 'muse-spark-1.3' }) + expect(model && 'options' in model ? model.options : []).toEqual([ + { value: 'muse-spark-1.3', name: 'Muse Spark 1.3' }, + ]) + expect(effort).toMatchObject({ id: 'effort', category: 'thought_level', currentValue: 'high' }) + }) + + it('lists contributor models and Bypass permissions only when the flags allow them', async () => { + const h = harness({ canBypass: true, allowsContributorModels: true }) + const created = await h.run((client) => start(client)) + expect(created.modes?.availableModes.map((mode) => mode.id)).toContain('bypassPermissions') + const [model] = created.configOptions ?? [] + expect(model && 'options' in model ? model.options.length : 0).toBe(2) + }) + + it('refuses a relative folder', async () => { + const h = harness() + await expect( + h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + return await client.request('session/new', { cwd: 'relative/path', mcpServers: [] }) + }), + ).rejects.toMatchObject({ code: -32_602 }) + }) + + it('streams a turn and answers end_turn only after every update went out', async () => { + const h = harness() + const response = await h.run(async (client) => { + const { sessionId } = await start(client) + return await turn(h, client, sessionId, (session) => { + session.emit( + { type: 'turnStarted', turnId: 'turn-1' }, + { type: 'itemStarted', item: message('m1', '') }, + { type: 'textDelta', itemId: 'm1', field: 'text', delta: 'Hel' }, + { type: 'textDelta', itemId: 'm1', field: 'text', delta: 'lo' }, + { type: 'itemCompleted', item: message('m1', 'Hello!', 'completed') }, + { type: 'todoChanged', items: [{ text: 'Write tests', status: 'in_progress' }] }, + ) + }) + }) + expect(response).toEqual({ stopReason: 'end_turn' }) + expect(h.updates).toEqual([ + { sessionUpdate: 'available_commands_update', availableCommands: [] }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hel' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'lo' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '!' } }, + { + sessionUpdate: 'plan', + entries: [{ content: 'Write tests', priority: 'medium', status: 'in_progress' }], + }, + ]) + }) + + it('announces skills as commands and runs /selector as the skill', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0] + if (session !== undefined) { + session.skills = [ + { selector: 'review', displayName: 'Review', description: '', argumentHint: '' }, + ] + } + const response = client.request('session/prompt', { + sessionId, + prompt: [{ type: 'text', text: '/review src/app.ts' }], + }) + await until(() => (session?.sendTurn.mock.calls.length ?? 0) > 0) + session?.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal: 'completed' }) + await response + }) + expect(h.updates[0]).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'review', description: 'Review', input: { hint: '' } }], + }) + expect(h.host.sessions[0]?.sendTurn).toHaveBeenCalledWith( + [{ type: 'skill', selector: 'review', arguments: 'src/app.ts' }], + '/review src/app.ts', + ) + }) + + it('answers cancelled when the client cancels, and passes the cancel to the backend', async () => { + const h = harness() + const response = await h.run(async (client) => { + const { sessionId } = await start(client) + return await turn( + h, + client, + sessionId, + async (session) => { + await client.notify('session/cancel', { sessionId }) + await until(() => session.cancel.mock.calls.length === 1) + }, + 'completed', + ) + }) + expect(response).toEqual({ stopReason: 'cancelled' }) + }) + + it('passes a cancel sent while the turn is starting to the backend once the turn exists', async () => { + const h = harness() + const response = await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + const gate = new AbortController() + session.sendTurn.mockImplementationOnce( + () => + new Promise((resolve) => { + gate.signal.addEventListener( + 'abort', + () => { + resolve({ turnId: 'turn-1', disposition: 'started' }) + }, + { once: true }, + ) + }), + ) + const answer = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length === 1) + await client.notify('session/cancel', { sessionId }) + await new Promise((resolve) => setTimeout(resolve, 20)) + // A stop sent before the turn exists would find nothing to stop. + expect(session.cancel).not.toHaveBeenCalled() + gate.abort() + await until(() => session.cancel.mock.calls.length === 1) + session.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal: 'cancelled' }) + return await answer + }) + expect(response).toEqual({ stopReason: 'cancelled' }) + }) + + it('ignores a cancel for a session it does not hold', async () => { + const h = harness() + // The SDK reports a notification handler's exception only here. + const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await h.run(async (client) => { + const { sessionId } = await start(client) + await client.notify('session/cancel', { sessionId: 'not-a-session' }) + await client.request('session/close', { sessionId }) + await client.notify('session/cancel', { sessionId }) + // A request after them is answered in order, so both were handled. + await start(client) + }) + expect(reported).not.toHaveBeenCalled() + } finally { + reported.mockRestore() + } + expect(h.host.sessions[0]?.cancel).not.toHaveBeenCalled() + }) + + it('turns a failed turn into an error with its reason', async () => { + const h = harness() + await expect( + h.run(async (client) => { + const { session, response } = await running(h, client) + session.emit({ + type: 'turnCompleted', + turnId: 'turn-1', + terminal: 'failed', + reason: 'model overloaded', + }) + return await response + }), + ).rejects.toThrow(/model overloaded/) + }) + + it('refuses a second prompt while one runs, and a prompt with content it cannot take', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0] + const first = prompt(client, sessionId) + await until(() => (session?.sendTurn.mock.calls.length ?? 0) > 0) + await expect(prompt(client, sessionId)).rejects.toThrow(UI_TEXT.acpPromptBusy) + session?.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal: 'completed' }) + await first + await expect( + client.request('session/prompt', { + sessionId, + prompt: [{ type: 'image', data: 'bm90IGFuIGltYWdl', mimeType: 'image/png' }], + }), + ).rejects.toThrow(UI_TEXT.attachmentUnsupported) + }) + }) + + it('settles a turn that finished before its id came back', async () => { + const h = harness() + const response = await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0] + session?.sendTurn.mockImplementationOnce(() => { + session.emit({ type: 'turnCompleted', turnId: 'turn-early', terminal: 'completed' }) + return Promise.resolve({ turnId: 'turn-early', disposition: 'started' }) + }) + return await prompt(client, sessionId) + }) + expect(response).toEqual({ stopReason: 'end_turn' }) + }) + + it('decides an approval with the option the client picked', async () => { + const h = harness({ + answer: () => ({ outcome: { outcome: 'selected', optionId: 'allow_session' } }), + }) + await approvalTurn(h) + expect(h.permissions[0]).toMatchObject({ + toolCall: { toolCallId: 'tool-1', title: 'PowerShell: npm test', kind: 'execute' }, + options: [ + { optionId: 'allow_once', kind: 'allow_once' }, + { optionId: 'allow_session', kind: 'allow_always' }, + { optionId: 'abort', kind: 'reject_once' }, + ], + }) + expect(h.host.sessions[0]?.decideApproval).toHaveBeenCalledWith({ + approvalId: 'approval-1', + choiceId: 'allow_session', + requirementId: { approvalId: 'approval-1', sourceIndex: 0 }, + }) + }) + + it('denies an approval the client cancelled, answered with an unknown option, or failed to answer', async () => { + const answers: PermissionAnswer[] = [ + () => ({ outcome: { outcome: 'cancelled' } }), + () => ({ outcome: { outcome: 'selected', optionId: 'invented' } }), + () => Promise.reject(new Error('client crashed')), + ] + for (const answer of answers) { + const h = harness({ answer }) + await approvalTurn(h) + expect(h.host.sessions[0]?.decideApproval).toHaveBeenCalledWith( + expect.objectContaining({ choiceId: 'abort' }), + ) + } + }) + + it('asks again for each stage of a staged command', async () => { + const h = harness({ + answer: () => ({ outcome: { outcome: 'selected', optionId: 'allow_once' } }), + }) + await approvalTurn(h, async (session) => { + session.emit({ + type: 'approvalUpdated', + approvalId: 'approval-1', + requirementId: { approvalId: 'approval-1', sourceIndex: 1 }, + subject: { kind: 'command', command: 'npm run build' }, + availableChoices: CHOICES, + }) + await until(() => session.decideApproval.mock.calls.length === 2) + session.emit({ + type: 'approvalResolved', + approvalId: 'approval-1', + itemId: 'tool-1', + decision: 'approved', + resolvedBy: 'user', + }) + }) + expect(h.permissions.map((request) => request.toolCall.title)).toEqual([ + 'PowerShell: npm test', + 'PowerShell: npm run build', + ]) + }) + + it('stops the turn when an approval offers no way to deny', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + await turn(h, client, sessionId, async (session) => { + session.emit(approval({ availableChoices: [CHOICES[0]!] })) + await until(() => session.cancel.mock.calls.length === 1) + }) + }) + expect(h.host.sessions[0]?.decideApproval).not.toHaveBeenCalled() + }) + + it('answers a plain file write itself in Edit automatically, as the panel does', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + await client.request('session/set_mode', { sessionId, modeId: 'acceptEdits' }) + await turn(h, client, sessionId, async (session) => { + session.emit( + approval({ + toolName: 'write_file', + subject: { kind: 'fileAccess', access: 'write', path: 'src/app.ts' }, + }), + ) + await until(() => session.decideApproval.mock.calls.length === 1) + }) + }) + expect(h.permissions).toEqual([]) + expect(h.host.sessions[0]?.setApprovalMode).toHaveBeenCalledWith('promptUnmatched') + expect(h.host.sessions[0]?.decideApproval).toHaveBeenCalledWith( + expect.objectContaining({ choiceId: 'allow_once' }), + ) + }) + + it('asks the question as a form where the client has forms', async () => { + const h = harness({ + elicitation: { action: 'accept', content: { color: 'Blue' } }, + }) + await askInForm(h, { + id: 'color', + header: 'Colour', + question: 'Which colour?', + selection: { mode: 'single' }, + options: [{ label: 'Blue' }, { label: 'Red' }], + }) + expect(h.elicitations[0]).toMatchObject({ + mode: 'form', + message: UI_TEXT.acpQuestionFormMessage, + }) + expect(h.host.sessions[0]?.answerQuestions).toHaveBeenCalledWith('input-1', [ + { questionId: 'color', selectedLabel: 'Blue' }, + ]) + }) + + it('declines a question the form was cancelled on, and shows it as text where there are no forms', async () => { + const withForms = harness({ elicitation: { action: 'decline' } }) + const withoutForms = harness() + // A form request that fails is declined too, so the turn goes on. + const brokenForms = harness({ isElicitationBroken: true }) + const question: AgentEvent = { + type: 'questionRequested', + userInputId: 'input-1', + itemId: 'q1', + questions: [ + { + id: 'q', + header: 'Go?', + question: 'Proceed?', + selection: { mode: 'single' }, + options: [{ label: 'Yes' }], + }, + ], + } + for (const [h, capabilities] of [ + [withForms, { elicitation: { form: {} } }], + [withoutForms, {}], + [brokenForms, { elicitation: { form: {} } }], + ] as const) { + await h.run(async (client) => { + const { sessionId } = await start(client, capabilities) + await turn(h, client, sessionId, async (session) => { + session.emit(question) + await until(() => session.cancelQuestions.mock.calls.length === 1) + }) + }) + } + expect(withoutForms.updates).toContainEqual({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `${UI_TEXT.acpQuestionAsked}\nProceed?\n- Yes` }, + }) + }) + + it('declines a form whose answer is not one of the options it offered', async () => { + const h = harness({ + elicitation: { action: 'accept', content: { parts: ['A', 'Z'] } }, + }) + await askInForm(h, { + id: 'parts', + header: 'Parts', + question: 'Which parts?', + selection: { mode: 'multiple' }, + options: [{ label: 'A' }, { label: 'B' }], + }) + expect(h.host.sessions[0]?.answerQuestions).not.toHaveBeenCalled() + expect(h.log.info).toHaveBeenCalledWith( + expect.stringContaining('question input-1 declined: the form came back without an answer'), + ) + }) + + it('logs a backend failure by its MSP kind and code, never the CLI’s message', async () => { + const personal = 'no such session under /home/someone for someone@example.com' + const refused = new MspError({ + code: -32_000, + message: personal, + data: { kind: 'commandRejected' }, + }) + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + await turn(h, client, sessionId, async (session) => { + session.decideApproval.mockRejectedValueOnce(refused) + session.cancelQuestions.mockRejectedValue(refused) + session.emit(approval()) + session.emit({ + type: 'questionRequested', + userInputId: 'input-1', + itemId: 'q1', + questions: [], + }) + await until(() => session.cancelQuestions.mock.calls.length === 2) + }) + }) + const logged = JSON.stringify([h.log.info.mock.calls, h.log.warn.mock.calls]) + expect(logged).toContain('approval approval-1: commandRejected (MSP error -32000)') + expect(logged).toContain('question input-1 not declined: commandRejected (MSP error -32000)') + expect(logged).not.toContain('/home/someone') + expect(logged).not.toContain('someone@example.com') + }) + + it('is busy while the skills are first announced, and a cancel then ends the prompt without a turn', async () => { + const h = harness() + const stop = await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + // The skills answer only once the gate opens. + const gate = new AbortController() + session.listSkills.mockImplementation( + () => + new Promise((resolve) => { + gate.signal.addEventListener( + 'abort', + () => { + resolve([]) + }, + { once: true }, + ) + }), + ) + const first = prompt(client, sessionId) + await until(() => session.listSkills.mock.calls.length === 1) + await expect(prompt(client, sessionId, 'again')).rejects.toMatchObject({ + message: expect.stringContaining(UI_TEXT.acpPromptBusy), + }) + await client.notify('session/cancel', { sessionId }) + await until(() => + h.log.info.mock.calls.some(([line]) => String(line).includes('cancelled before its turn')), + ) + gate.abort() + return await first + }) + expect(stop).toEqual({ stopReason: 'cancelled' }) + expect(h.host.sessions[0]?.sendTurn).not.toHaveBeenCalled() + }) + + it('switches the model and the effort, and refuses what the session does not offer', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0] + const switched = await client.request('session/set_config_option', { + sessionId, + configId: 'effort', + value: 'low', + }) + expect(session?.setReasoningEffort).toHaveBeenLastCalledWith('low') + expect(switched.configOptions[1]).toMatchObject({ currentValue: 'low' }) + await client.request('session/set_config_option', { + sessionId, + configId: 'model', + value: 'muse-spark-1.3', + }) + expect(session?.setModel).toHaveBeenCalledWith('muse-spark-1.3') + for (const [configId, value] of [ + ['model', 'muse-spark-1.3-contributor'], + ['effort', 'turbo'], + ['colour', 'blue'], + ] as const) { + await expect( + client.request('session/set_config_option', { sessionId, configId, value }), + ).rejects.toMatchObject({ code: -32_602 }) + } + await expect( + client.request('session/set_config_option', { + sessionId, + configId: 'model', + type: 'boolean', + value: true, + }), + ).rejects.toMatchObject({ code: -32_602 }) + await expect( + client.request('session/set_mode', { sessionId, modeId: 'bypassPermissions' }), + ).rejects.toMatchObject({ code: -32_602 }) + }) + }) + + it('follows the backend when it changes the model or the effort itself', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + await turn(h, client, sessionId, (session) => { + session.emit( + { type: 'effortChanged', effort: 'medium' }, + { type: 'effortChanged', effort: 'none' }, + { type: 'modelChanged', modelId: 'muse-spark-1.3' }, + ) + }) + }) + const configUpdates = h.updates.filter( + (update) => update.sessionUpdate === 'config_option_update', + ) + expect(configUpdates).toHaveLength(2) + }) + + it('loads a session with its history replayed and its plan, and resumes one without', async () => { + const h = harness() + h.host.history = { + items: [ + { itemId: 'u1', kind: 'userMessage', status: 'completed', text: 'Fix the bug' }, + { itemId: 'a1', kind: 'agentMessage', status: 'completed', text: 'Done.' }, + ], + todos: [{ text: 'Fix the bug', status: 'completed' }], + } + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + const loaded = await client.request('session/load', { + sessionId: 'old-1', + cwd: CWD, + mcpServers: [], + }) + expect(loaded.modes?.currentModeId).toBe('manual') + await client.request('session/resume', { sessionId: 'old-2', cwd: CWD }) + await client.request('session/resume', { sessionId: 'old-2', cwd: CWD }) + }) + // The mode the editor is told is set on the backend, whatever the session last ran in. + for (const session of h.host.sessions) { + expect(session.setApprovalMode).toHaveBeenCalledWith(approvalModeFor('manual', true)) + } + // Resumed again, the session held before is let go. + expect(h.host.sessions[1]?.dispose).toHaveBeenCalledTimes(1) + expect(h.host.sessions[2]?.dispose).not.toHaveBeenCalled() + expect(h.updates).toEqual([ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Fix the bug' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Done.' } }, + { + sessionUpdate: 'plan', + entries: [{ content: 'Fix the bug', priority: 'medium', status: 'completed' }], + }, + ]) + expect(h.host.resumeSession).toHaveBeenCalledTimes(3) + }) + + it('fails a load whose mode the backend refuses, and lets that session go', async () => { + const h = harness() + await failedLoad( + h, + (session) => { + vi.mocked(session.setApprovalMode).mockRejectedValue(new Error('refused')) + }, + async (client) => { + await expect(prompt(client, 'old-1')).rejects.toThrow() + }, + ) + expect(h.updates).toEqual([]) + }) + + it('runs a loaded session on the model the backend reports, never the one its handle holds (Codex on 4eb0156c)', async () => { + const h = harness() + // Muse Code's resumed handle holds the model the agent asked for; the + // CLI keeps the contributor model the session last ran on. + h.host.listModels.mockImplementation((sessionId) => + Promise.resolve( + sessionId === undefined + ? h.host.models + : h.host.models.map((model) => ({ + ...model, + isActive: model.modelId === 'muse-spark-1.3-contributor', + })), + ), + ) + const loaded = await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + return await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + }) + const session = h.host.sessions[0]! + expect(session.modelId).toBe('muse-spark-1.3') + expect(h.host.listModels).toHaveBeenCalledWith('old-1') + expect(loaded.modes?.currentModeId).toBe('manual') + expect(session.setApprovalMode).toHaveBeenCalledWith('promptUnmatched') + expect(session.setModel).toHaveBeenCalledWith('muse-spark-1.3') + expect(loaded.configOptions?.find((option) => option.id === 'model')?.currentValue).toBe( + 'muse-spark-1.3', + ) + expect(session.setReasoningEffort).toHaveBeenCalledTimes(1) + }) + + it('keeps a listed model the backend reports active, and sets the default where it reports none', async () => { + const other: ModelSummary = { + modelId: 'muse-spark-1.2', + displayLabel: 'Muse Spark 1.2', + contextLimit: 1_000_000, + isDefault: false, + isActive: false, + } + const h = harness() + h.host.models = [...FAKE_MODELS.map((model) => ({ ...model, isActive: false })), other] + h.host.listModels.mockImplementation((sessionId) => + Promise.resolve( + h.host.models.map((model) => ({ + ...model, + isActive: sessionId === 'old-1' && model.modelId === other.modelId, + })), + ), + ) + const [kept, unreported] = await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + return [ + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }), + await client.request('session/resume', { sessionId: 'old-2', cwd: CWD }), + ] + }) + expect(h.host.sessions[0]?.setModel).not.toHaveBeenCalled() + expect(kept.configOptions?.find((option) => option.id === 'model')?.currentValue).toBe( + 'muse-spark-1.2', + ) + expect(h.host.sessions[1]?.setModel).toHaveBeenCalledWith('muse-spark-1.3') + expect(unreported.configOptions?.find((option) => option.id === 'model')?.currentValue).toBe( + 'muse-spark-1.3', + ) + }) + + it('lets a new session go when its effort is refused, and holds nothing (Codex on 4eb0156c)', async () => { + const h = harness() + const startNew = h.host.startSession.getMockImplementation()! + h.host.startSession.mockImplementationOnce(async (options) => { + const started = await startNew(options) + vi.mocked(started.setReasoningEffort).mockRejectedValue(new Error('refused')) + return started + }) + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await expect(client.request('session/new', { cwd: CWD, mcpServers: [] })).rejects.toThrow() + await expect(prompt(client, 'session-1')).rejects.toThrow() + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + }) + + it('lets the held session go before a reload runs on it, and holds nothing if the reload fails (Grok on 78a74430)', async () => { + const h = harness() + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + retainOnResume(h, h.host.sessions[0]!) + h.host.listModels.mockImplementation((sessionId) => + sessionId === undefined + ? Promise.resolve(h.host.models) + : Promise.reject(new Error('no model list')), + ) + await expect( + client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }), + ).rejects.toThrow() + // Nothing is held for it, so nothing is shown that does not run. + await expect( + client.request('session/set_mode', { sessionId: 'old-1', modeId: 'plan' }), + ).rejects.toThrow() + h.host.listModels.mockImplementation(() => Promise.resolve(h.host.models)) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + await client.request('session/set_mode', { sessionId: 'old-1', modeId: 'plan' }) + }) + const shared = h.host.sessions[0]! + // The first hold went with the reload, the failed reload's with it. + expect(shared.dispose).toHaveBeenCalledTimes(2) + expect(shared.setApprovalMode).toHaveBeenLastCalledWith(approvalModeFor('plan', true)) + }) + + it('follows a session loaded again once, not once for each load (Grok on 78a74430)', async () => { + const h = harness() + const configUpdates = () => + h.updates.filter((update) => update.sessionUpdate === 'config_option_update') + await h.run(async (client) => { + const shared = await loadOld(h, client) + retainOnResume(h, shared) + // Muse Code tells of the effort it was set to. + shared.setReasoningEffort.mockImplementation(() => { + shared.emit({ type: 'effortChanged', effort: 'medium' }) + return Promise.resolve() + }) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + await until(() => configUpdates().length > 0) + await settled() + }) + expect(configUpdates()).toHaveLength(1) + }) + + it('ends a closed session’s prompt cancelled, stops its turn, and its late approval decides nothing (Grok on 78a74430, 5e2b85c3)', async () => { + const answer = Promise.withResolvers() + const h = harness({ answer: () => answer.promise }) + const stop = await h.run(async (client) => { + const { sessionId, session, response } = await running(h, client) + session.emit(approval()) + await until(() => h.permissions.length === 1) + await client.request('session/close', { sessionId }) + answer.resolve({ outcome: { outcome: 'selected', optionId: 'allow_once' } }) + await settled() + return await response + }) + expect(stop).toEqual({ stopReason: 'cancelled' }) + expect(h.host.sessions[0]?.decideApproval).not.toHaveBeenCalled() + // The editor was told it stopped, so it stops on the backend too. + expect(h.host.sessions[0]?.cancel).toHaveBeenCalledTimes(1) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['has started', true], + // Past its deadline a start fails, yet may still start (Grok on ca263c53). + ['failed to start', false], + ])( + 'closed while its turn is being started, it stops the turn once the start is answered: %s (Grok on 5e2b85c3)', + async (_name, isStarted) => { + const h = harness() + const starting = Promise.withResolvers<{ turnId: string; disposition: 'started' }>() + const stop = await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + session.sendTurn.mockImplementation(() => starting.promise) + const response = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length === 1) + const closed = client.request('session/close', { sessionId }) + await settled() + // Nothing is stopped before there is a turn to stop. + expect(session.cancel).not.toHaveBeenCalled() + if (isStarted) { + starting.resolve({ turnId: 'turn-1', disposition: 'started' }) + } else { + starting.reject(new Error('not started')) + } + await closed + return await response + }) + expect(stop).toEqual({ stopReason: 'cancelled' }) + expect(h.host.sessions[0]?.cancel).toHaveBeenCalledTimes(1) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + }, + ) + + it('follows a session loaded again only once its running turn is stopped (Grok on ca263c53)', async () => { + const h = harness() + const starting = Promise.withResolvers<{ turnId: string; disposition: 'started' }>() + await h.run(async (client) => { + const shared = await loadOld(h, client) + shared.sendTurn.mockImplementation(() => starting.promise) + const response = prompt(client, 'old-1') + await until(() => shared.sendTurn.mock.calls.length === 1) + // An approval open on that turn, which Muse Code hands a new listener. + shared.openPrompts = [approval()] + const subscribe = vi.spyOn(shared, 'onEvent') + retainOnResume(h, shared) + const reload = client.request('session/load', { + sessionId: 'old-1', + cwd: CWD, + mcpServers: [], + }) + await settled() + // The reload waits for the turn to be stopped before it follows. + expect(subscribe).not.toHaveBeenCalled() + expect(h.permissions).toEqual([]) + starting.resolve({ turnId: 'turn-1', disposition: 'started' }) + await reload + await response + expect(shared.cancel.mock.invocationCallOrder[0]).toBeLessThan( + subscribe.mock.invocationCallOrder[0]!, + ) + }) + }) + + it('lets a load being set up go when its backend stops, and the load fails (Grok on ca263c53)', async () => { + const h = harness() + const mode = Promise.withResolvers() + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + onNextResume(h, (session) => { + vi.mocked(session.setApprovalMode).mockImplementationOnce(() => mode.promise) + }) + const loading = client.request('session/resume', { sessionId: 'old-1', cwd: CWD }) + await until(() => h.host.sessions[0]?.setApprovalMode.mock.calls.length === 1) + h.host.exit('the backend stopped') + mode.resolve(undefined) + await expect(loading).rejects.toThrow() + await expect(prompt(client, 'old-1')).rejects.toThrow() + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + }) + + it('stops the turn of a session loaded again while it runs (Grok on 5e2b85c3)', async () => { + const h = harness() + const stop = await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + const shared = h.host.sessions[0]! + const response = prompt(client, 'old-1') + await until(() => shared.sendTurn.mock.calls.length === 1) + retainOnResume(h, shared) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + return await response + }) + expect(stop).toEqual({ stopReason: 'cancelled' }) + expect(h.host.sessions[0]?.cancel).toHaveBeenCalledTimes(1) + }) + + it('lets a load still being set up go for a newer load of the same session (Grok on 5e2b85c3)', async () => { + const h = harness() + const firstMode = Promise.withResolvers() + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + // The first load's session: its mode is set only when the test lets it. + onNextResume(h, (session) => { + vi.mocked(session.setApprovalMode).mockImplementationOnce(() => firstMode.promise) + }) + const first = client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + await until(() => h.host.sessions.length === 1) + const shared = h.host.sessions[0]! + await until(() => shared.setApprovalMode.mock.calls.length === 1) + retainOnResume(h, shared) + const second = client.request('session/load', { + sessionId: 'old-1', + cwd: CWD, + mcpServers: [], + }) + await until(() => shared.setApprovalMode.mock.calls.length === 2) + firstMode.resolve(undefined) + await expect(first).rejects.toThrow() + await second + // Only the newer load follows the session and asks the editor. + shared.emit(approval()) + await until(() => h.permissions.length === 1) + await settled() + }) + expect(h.permissions).toHaveLength(1) + // The superseded load's hold on the shared session went, the newer one's stays. + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + // The superseded load changed nothing once let go: the effort is the newer load's. + expect(h.host.sessions[0]?.setReasoningEffort).toHaveBeenCalledTimes(1) + }) + + it.each([ + // Its first step, and its last (after which nothing more checks). + ['mode', 'setApprovalMode'], + ['effort', 'setReasoningEffort'], + ] as const)( + 'lets a resume go when the editor closes that session while its %s is being set', + async (_name, step) => { + const h = harness() + const held = Promise.withResolvers() + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + onNextResume(h, (session) => { + vi.mocked(session[step]).mockImplementationOnce(() => held.promise) + }) + // Resumed: no replay follows, so the last step is the effort. + const loading = client.request('session/resume', { sessionId: 'old-1', cwd: CWD }) + await until(() => h.host.sessions[0]?.[step].mock.calls.length === 1) + await client.request('session/close', { sessionId: 'old-1' }) + held.resolve(undefined) + await expect(loading).rejects.toThrow() + await expect(prompt(client, 'old-1')).rejects.toThrow() + // Closing what is not held is refused. + await expect(client.request('session/close', { sessionId: 'old-1' })).rejects.toThrow() + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + // Nothing more was set once it was let go. + expect(h.host.sessions[0]?.setReasoningEffort).toHaveBeenCalledTimes( + step === 'setApprovalMode' ? 0 : 1, + ) + }, + ) + + it('ends a prompt cancelled when its session is closed while the skills are announced', async () => { + const h = harness() + const skills = Promise.withResolvers() + const stop = await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + session.listSkills.mockImplementation(() => skills.promise) + const first = prompt(client, sessionId) + await until(() => session.listSkills.mock.calls.length === 1) + await client.request('session/close', { sessionId }) + skills.resolve([]) + return await first + }) + expect(stop).toEqual({ stopReason: 'cancelled' }) + expect(h.host.sessions[0]?.sendTurn).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'answered', + (form: PromiseWithResolvers) => { + form.resolve({ action: 'accept', content: { color: 'Blue' } }) + }, + ], + [ + 'failed', + (form: PromiseWithResolvers) => { + form.reject(new Error('the form went away')) + }, + ], + ])( + 'neither answers nor declines a question whose form is %s after its session closed', + async (_name, settle) => { + const { form, h } = delayedForm() + await h.run(async (client) => { + const { sessionId, session } = await startFormSession(h, client) + session.emit(colourQuestion()) + await until(() => h.elicitations.length === 1) + await client.request('session/close', { sessionId }) + settle(form) + await settled() + }) + expect(h.host.sessions[0]?.answerQuestions).not.toHaveBeenCalled() + expect(h.host.sessions[0]?.cancelQuestions).not.toHaveBeenCalled() + }, + ) + + it('lets a session go whose events cannot be followed', async () => { + await failedLoad(harness(), (session) => { + vi.spyOn(session, 'onEvent').mockImplementation(() => { + throw new Error('no events') + }) + }) + }) + + it('logs a backend failure by its kind, never its message (Codex on a209130)', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + session.listSkills.mockRejectedValue( + new Error(String.raw`cannot read C:\Users\person\secret.json for person@example.com`), + ) + await turn(h, client, sessionId, () => undefined) + }) + const logged = JSON.stringify(h.log.warn.mock.calls) + expect(logged).toContain('skills unavailable: Error') + expect(logged).not.toContain('person') + }) + + it('lists the folder’s sessions, the agent’s own folder when none is named', async () => { + const h = harness() + h.host.page = { + sessions: [ + { + sessionId: 's1', + name: 'Refactor', + createdAt: '2026-09-25T00:00:00Z', + updatedAt: '2026-09-26T00:00:00Z', + status: 'idle', + turnCount: 3, + }, + ], + nextCursor: 'next', + } + const listed = await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + return await client.request('session/list', { cursor: 'c1' }) + }) + expect(h.host.listSessions).toHaveBeenCalledWith({ + workspaceRoot: CWD, + limit: 50, + cursor: 'c1', + }) + expect(listed).toEqual({ + sessions: [ + { sessionId: 's1', cwd: CWD, title: 'Refactor', updatedAt: '2026-09-26T00:00:00Z' }, + ], + nextCursor: 'next', + }) + }) + + it('closes a session, and refuses a session it does not hold', async () => { + const h = harness() + await h.run(async (client) => { + const { sessionId } = await start(client) + await client.request('session/close', { sessionId }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalled() + await expect(prompt(client, sessionId)).rejects.toMatchObject({ code: -32_002 }) + }) + }) + + it('fails the running prompt when the backend stops', async () => { + const h = harness() + await expect( + h.run(async (client) => { + const { response } = await running(h, client) + h.host.exit('killed by signal 9') + return await response + }), + ).rejects.toThrow(/killed by signal 9/) + expect(h.log.warn).toHaveBeenCalledWith('The museCode backend stopped: killed by signal 9') + }) +}) + +/** Answers a permission prompt with the option of that id. */ +function choose(optionId: string): PermissionAnswer { + return () => ({ outcome: { outcome: 'selected', optionId } }) +} + +const WEB_SEARCH = { feature: 'webSearch' } as const +const IMAGE = { + feature: 'imageGeneration', + kind: 'generate', + path: 'logo.png', + sources: [], + prompt: 'a logo', +} as const +const SUBAGENT_TASK = { + feature: 'subagents', + task: { role: 'explorer', objective: 'Map files', modelId: 'muse-spark-1.3', attemptLimit: 4 }, +} as const + +/** A session, then the backend's question before each use in it, in turn (M58). */ +async function answersInOneSession( + h: Harness, + requests: readonly PaidUseRequest[], +): Promise { + return await h.run(async (client) => { + const { sessionId } = await start(client) + const answers: boolean[] = [] + for (const request of requests) { + answers.push(await h.paid.allows(CWD, sessionId, request, false)) + } + return answers + }) +} + +describe('paid features in the agent (M63c, M58)', () => { + it.each(['cancelled', 'completed'])( + 'never remembers a paid answer arriving after the prompt %s', + async (terminal) => { + const { answer, h } = delayedPaidSearch() + await h.run(async (client) => { + const active = await running(h, client) + const { sessionId } = active + const paid = h.paid.allows(CWD, sessionId, WEB_SEARCH, false) + await until(() => h.permissions.length === 1) + await finishRunningPrompt(client, active, terminal) + answer.resolve({ outcome: { outcome: 'selected', optionId: 'paid-allow-always' } }) + expect(await paid).toBe(false) + expect(h.paid.isRemembered(CWD, 'webSearch')).toBe(false) + expect(h.grants.byFolder.size).toBe(0) + // A fresh use still asks and can be allowed; only the stale answer was refused. + expect(await h.paid.allows(CWD, sessionId, WEB_SEARCH, false)).toBe(true) + expect(h.permissions).toHaveLength(2) + }) + }, + ) + + it('denies a paid answer while its prompt is cancelled before its turn starts', async () => { + const { answer, h } = delayedPaidSearch() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + let isAllowed = true + session.listSkills.mockImplementation(async () => { + isAllowed = await h.paid.allows(CWD, sessionId, WEB_SEARCH, false) + return [] + }) + const response = prompt(client, sessionId) + await until(() => h.permissions.length === 1) + await client.notify('session/cancel', { sessionId }) + await until(() => + h.log.info.mock.calls.some( + ([message]) => + typeof message === 'string' && message.includes('cancelled before its turn started'), + ), + ) + answer.resolve({ outcome: { outcome: 'selected', optionId: 'paid-allow-always' } }) + expect(await response).toEqual({ stopReason: 'cancelled' }) + expect(isAllowed).toBe(false) + expect(h.grants.byFolder.size).toBe(0) + expect(session.sendTurn).not.toHaveBeenCalled() + }) + }) + + it('denies a paid use answered after its session closed (Grok on 78a74430)', async () => { + const answer = Promise.withResolvers() + const h = harness({ kind: 'modelApi', paid: ['webSearch'], answer: () => answer.promise }) + const isAllowed = await h.run(async (client) => { + const { sessionId } = await start(client) + const asked = h.paid.allows(CWD, sessionId, WEB_SEARCH, false) + await until(() => h.permissions.length === 1) + await client.request('session/close', { sessionId }) + answer.resolve({ outcome: { outcome: 'selected', optionId: 'paid-allow-once' } }) + return await asked + }) + expect(isAllowed).toBe(false) + // Nothing more reaches the editor for a session it closed (Grok on ca263c53). + expect(h.updates.filter((update) => update.sessionUpdate === 'tool_call_update')).toEqual([]) + }) + + it('denies without asking a feature it has no flag for, and subagents always', async () => { + const h = harness({ kind: 'modelApi', paid: ['webSearch'], answer: choose('paid-allow-once') }) + expect(await answersInOneSession(h, [IMAGE, SUBAGENT_TASK])).toEqual([false, false]) + expect(h.permissions).toEqual([]) + }) + + it('asks before each use in its session, naming the price; Allow once allows that use only', async () => { + const h = harness({ kind: 'modelApi', paid: ['webSearch'], answer: choose('paid-allow-once') }) + expect(await answersInOneSession(h, [WEB_SEARCH, WEB_SEARCH])).toEqual([true, true]) + expect(h.permissions).toHaveLength(2) + const [asked] = h.permissions + const id = asked?.toolCall.toolCallId + expect(id).toMatch(/^paid-use-[\da-f-]{36}$/) + expect(asked?.toolCall.title).toBe('Let Muse search the web for this prompt?') + expect(JSON.stringify(asked?.toolCall.content)).toContain('$2.50 per 1,000 searches') + // Not trusted: "Allow always" is neither offered nor kept. + expect(asked?.options).toEqual([ + { optionId: 'paid-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'paid-deny', name: 'Deny', kind: 'reject_once' }, + ]) + // Each question its own row, however the session came to be held. + expect(h.permissions[1]?.toolCall.toolCallId).not.toBe(id) + const row = h.updates.find( + (update) => update.sessionUpdate === 'tool_call' && update.toolCallId === id, + ) + expect(JSON.stringify(row)).toContain('$2.50 per 1,000 searches') + expect(h.updates).toContainEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: id, + status: 'completed', + }) + expect(h.grants.byFolder.size).toBe(0) + }) + + it('stops asking in a trusted folder allowed always, and keeps that for the folder', async () => { + const h = harness({ + kind: 'modelApi', + paid: ['webSearch', 'imageGeneration'], + isTrusted: true, + answer: choose('paid-allow-always'), + }) + const answers = await answersInOneSession(h, [WEB_SEARCH, WEB_SEARCH, IMAGE]) + expect(answers).toEqual([true, true, true]) + // The second search asked nothing; the image is a feature of its own. + expect(h.permissions.map((request) => request.toolCall.title)).toEqual([ + 'Let Muse search the web for this prompt?', + 'Muse wants to create the image logo.png', + ]) + expect(h.permissions[0]?.options.map((option) => option.kind)).toEqual([ + 'allow_once', + 'allow_always', + 'reject_once', + ]) + expect(h.grants.byFolder.get(CWD)).toEqual(new Set(['webSearch', 'imageGeneration'])) + expect(h.paid.isRemembered(CWD, 'webSearch')).toBe(true) + }) + + it.each([ + ['Deny', choose('paid-deny')], + ['a cancel', (() => ({ outcome: { outcome: 'cancelled' } })) satisfies PermissionAnswer], + ['an option it was not offered', choose('paid-allow-always')], + ['an unknown option', choose('turn-on')], + ])('denies on %s, and keeps nothing', async (_name, answer: PermissionAnswer) => { + const h = harness({ kind: 'modelApi', paid: ['imageGeneration'], answer }) + expect(await answersInOneSession(h, [IMAGE])).toEqual([false]) + expect(h.permissions).toHaveLength(1) + expect(h.updates).toContainEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: h.permissions[0]?.toolCall.toolCallId, + status: 'failed', + }) + expect(h.grants.byFolder.size).toBe(0) + }) + + it('denies when the client cannot answer, or the session is not one the agent holds', async () => { + const h = harness({ + kind: 'modelApi', + paid: ['webSearch'], + answer: () => { + throw new Error('no permission prompts here') + }, + }) + expect(await answersInOneSession(h, [WEB_SEARCH])).toEqual([false]) + expect(await h.paid.allows(CWD, 'not-a-session', WEB_SEARCH, false)).toBe(false) + expect(h.permissions).toHaveLength(1) + expect(h.log.warn).toHaveBeenCalledWith( + expect.stringContaining('the paid-use question failed, denying'), + ) + expect(h.log.warn).toHaveBeenCalledWith( + 'Paid use of webSearch: no editor session not-a-session to ask in, so it is denied', + ) + }) + + it('names the price on a paid row', async () => { + const h = harness({ kind: 'modelApi' }) + await h.run(async (client) => { + const { sessionId } = await start(client) + await turn(h, client, sessionId, (session) => { + session.emit({ + type: 'itemStarted', + item: { + itemId: 'search-1', + kind: 'toolCall', + status: 'inProgress', + turnId: 'turn-1', + tool: 'web_search', + args: JSON.stringify({ query: 'acp' }), + paid: 'webSearch', + }, + }) + }) + }) + const row = h.updates.find( + (update) => update.sessionUpdate === 'tool_call' && update.toolCallId === 'search-1', + ) + expect(row).toMatchObject({ + title: expect.stringContaining('(Billed to your Model API key: $2.50 per 1,000 searches)'), + }) + }) +}) + +describe('ACP session ownership across asynchronous releases', () => { + it.each(['cancelled', 'completed'])( + 'ignores an ordinary approval answer after its prompt %s and a replacement starts', + async (terminal) => { + const answer = Promise.withResolvers() + const h = harness({ answer: () => answer.promise }) + await h.run(async (client) => { + const active = await running(h, client) + const { sessionId, session } = active + session.emit(approval()) + await until(() => h.permissions.length === 1) + await finishRunningPrompt(client, active, terminal) + const fresh = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length === 2) + answer.resolve({ outcome: { outcome: 'selected', optionId: 'allow_once' } }) + await settled() + expect(session.decideApproval).not.toHaveBeenCalled() + session.emit({ type: 'turnCompleted', turnId: 'turn-2', terminal: 'completed' }) + expect(await fresh).toEqual({ stopReason: 'end_turn' }) + }) + }, + ) + + it('ignores an answer for an approval stage that advanced while the editor was asked', async () => { + const oldAnswer = Promise.withResolvers() + const nextAnswer = Promise.withResolvers() + let answers = 0 + const h = harness({ answer: () => (++answers === 1 ? oldAnswer.promise : nextAnswer.promise) }) + await h.run(async (client) => { + const { session, response } = await running(h, client) + session.emit(approval()) + await until(() => h.permissions.length === 1) + session.emit({ + type: 'approvalUpdated', + approvalId: 'approval-1', + requirementId: { approvalId: 'approval-1', sourceIndex: 1 }, + subject: { kind: 'command', command: 'npm run build' }, + availableChoices: CHOICES, + }) + await until(() => h.permissions.length === 2) + oldAnswer.resolve({ outcome: { outcome: 'selected', optionId: 'allow_once' } }) + nextAnswer.resolve({ outcome: { outcome: 'selected', optionId: 'abort' } }) + await until(() => session.decideApproval.mock.calls.length > 0) + await settled() + expect(session.decideApproval).toHaveBeenCalledTimes(1) + expect(session.decideApproval).toHaveBeenCalledWith({ + approvalId: 'approval-1', + choiceId: 'abort', + requirementId: { approvalId: 'approval-1', sourceIndex: 1 }, + }) + session.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal: 'completed' }) + await response + }) + }) + + it.each(['accept', 'decline', 'failure'])( + 'neither answers nor declines a question after its prompt finished: %s', + async (action) => { + const { form, h } = delayedForm() + await h.run(async (client) => { + const { sessionId, session } = await startFormSession(h, client) + const response = prompt(client, sessionId) + await until(() => session.sendTurn.mock.calls.length === 1) + session.emit(colourQuestion()) + await until(() => h.elicitations.length === 1) + session.emit({ type: 'turnCompleted', turnId: 'turn-1', terminal: 'completed' }) + await response + if (action === 'failure') { + form.reject(new Error('form failed')) + } else { + form.resolve( + action === 'accept' ? { action, content: { color: 'Blue' } } : { action: 'decline' }, + ) + } + await settled() + expect(session.answerQuestions).not.toHaveBeenCalled() + expect(session.cancelQuestions).not.toHaveBeenCalled() + }) + }, + ) + + it.each(['turn/start', 'turn/cancel'])( + 'keeps the newest reload waiting while the old %s is unanswered', + async (heldMethod) => { + await withMspSession(async (client, wire) => { + const { response: oldPrompt } = await pendingMspPrompt(client, wire) + if (heldMethod === 'turn/cancel') { + answerMsp(wire.server, 'turn/start', 0, { turnId: 'turn-1', status: 'accepted' }) + wire.server.silence('turn/cancel') + } + const older = didRequestSucceed(loadStoredSession(client)) + await until(() => wire.server.requestsFor('session/resume').length === 2) + if (heldMethod === 'turn/cancel') { + await until(() => wire.server.requestsFor('turn/cancel').length === 1) + } + const newestStatus = { isDone: false } + const newest = didRequestSucceed( + client.request('session/resume', { sessionId: 'old-1', cwd: CWD }), + newestStatus, + ) + await until(() => wire.server.requestsFor('session/resume').length === 3) + await settled() + const attachedBeforeRelease = wire.server.requestsFor('session/setApprovalMode').length + const wasCompletedBeforeRelease = newestStatus.isDone + answerMsp( + wire.server, + heldMethod, + 0, + heldMethod === 'turn/start' ? { turnId: 'turn-1', status: 'accepted' } : {}, + ) + const hasOlderSucceeded = await older + const hasNewestSucceeded = await newest + expect(await oldPrompt).toEqual({ stopReason: 'cancelled' }) + expect(wasCompletedBeforeRelease).toBe(false) + expect(attachedBeforeRelease).toBe(1) + expect(hasOlderSucceeded).toBe(false) + expect(hasNewestSucceeded).toBe(true) + expect(wire.server.requestsFor('turn/cancel')).toHaveLength(1) + const fresh = prompt(client, 'old-1') + await until(() => wire.server.requestsFor('turn/start').length === 2) + answerMsp(wire.server, 'turn/start', 1, { turnId: 'turn-2', status: 'accepted' }) + wire.server.notify('turn/completed', { + sessionId: 'old-1', + turnId: 'turn-2', + terminal: 'completed', + }) + expect(await fresh).toEqual({ stopReason: 'end_turn' }) + }) + }, + ) + + it('waits for a closing session to stop before a newer reload attaches', async () => { + await withMspSession(async (client, wire) => { + const { response: oldPrompt } = await pendingMspPrompt(client, wire) + const closed = client.request('session/close', { sessionId: 'old-1' }) + await settled() + const loadedStatus = { isDone: false } + const loaded = didRequestSucceed( + client.request('session/resume', { sessionId: 'old-1', cwd: CWD }), + loadedStatus, + ) + await until(() => wire.server.requestsFor('session/resume').length === 2) + await settled() + const wasAttachedBeforeRelease = loadedStatus.isDone + answerMsp(wire.server, 'turn/start', 0, { turnId: 'turn-1', status: 'accepted' }) + await closed + expect(await loaded).toBe(true) + expect(await oldPrompt).toEqual({ stopReason: 'cancelled' }) + expect(wasAttachedBeforeRelease).toBe(false) + expect(wire.server.requestsFor('turn/cancel')).toHaveLength(1) + }) + }) + + it('closes a reload still waiting for the old release, and leaves no session held', async () => { + await withMspSession(async (client, wire) => { + const { response: oldPrompt } = await pendingMspPrompt(client, wire) + const loading = didRequestSucceed(loadStoredSession(client)) + await until(() => wire.server.requestsFor('session/resume').length === 2) + await settled() + const closed = didRequestSucceed(client.request('session/close', { sessionId: 'old-1' })) + await settled() + answerMsp(wire.server, 'turn/start', 0, { turnId: 'turn-1', status: 'accepted' }) + expect(await closed).toBe(true) + expect(await loading).toBe(false) + expect(await oldPrompt).toEqual({ stopReason: 'cancelled' }) + await expect(prompt(client, 'old-1')).rejects.toThrow() + }) + }) + + it('refuses an older resume whose backend answer arrives after a newer load', async () => { + const h = harness() + const firstResume = Promise.withResolvers>>() + const resume = h.host.resumeSession.getMockImplementation()! + let older: Awaited> | undefined + h.host.resumeSession.mockImplementationOnce(async (...args) => { + older = await resume(...args) + return await firstResume.promise + }) + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + const pending = didRequestSucceed( + client.request('session/resume', { sessionId: 'old-1', cwd: CWD }), + ) + await until(() => older !== undefined) + await client.request('session/load', { sessionId: 'old-1', cwd: CWD, mcpServers: [] }) + firstResume.resolve(older!) + expect(await pending).toBe(false) + await client.request('session/set_mode', { sessionId: 'old-1', modeId: 'plan' }) + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + expect(h.host.sessions[1]?.dispose).not.toHaveBeenCalled() + expect(h.host.sessions[1]?.setApprovalMode).toHaveBeenLastCalledWith('denyUnmatched') + }) + + it('releases only a stale resume’s retained hold and leaves the newer real session usable', async () => { + await withMspSession(async (client, wire) => { + wire.server.silence('session/resume') + const older = didRequestSucceed(loadStoredSession(client)) + await until(() => wire.server.requestsFor('session/resume').length === 2) + const newer = didRequestSucceed(loadStoredSession(client)) + await until(() => wire.server.requestsFor('session/resume').length === 3) + answerMsp(wire.server, 'session/resume', 2, acpResumeEnvelope()) + expect(await newer).toBe(true) + answerMsp(wire.server, 'session/resume', 1, acpResumeEnvelope()) + expect(await older).toBe(false) + expect(wire.host.sessionCount).toBe(1) + expect(wire.server.requestsFor('task/stopAll')).toEqual([]) + wire.server.silence('turn/start') + const fresh = prompt(client, 'old-1') + await until(() => wire.server.requestsFor('turn/start').length === 1) + answerMsp(wire.server, 'turn/start', 0, { turnId: 'fresh', status: 'accepted' }) + wire.server.notify('turn/completed', { + sessionId: 'old-1', + turnId: 'fresh', + terminal: 'completed', + }) + expect(await fresh).toEqual({ stopReason: 'end_turn' }) + }) + }) + + it('fails a prompt without an unhandled rejection when the real backend exits during turn/start', async () => { + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await withMspSession(async (client, wire) => { + wire.server.silence('turn/start') + const response = didRequestSucceed(prompt(client, 'old-1')) + await until(() => wire.server.requestsFor('turn/start').length === 1) + wire.exit(1) + // The process exit and the pipe ending can arrive on separate ticks. + await settled() + wire.server.close() + expect(await response).toBe(false) + await settled() + expect(unhandled).not.toHaveBeenCalled() + await expect(prompt(client, 'old-1')).rejects.toThrow() + }) + } finally { + process.off('unhandledRejection', unhandled) + } + }) + + it('fails a preparing prompt when the backend exits and sends no turn afterward', async () => { + const h = harness() + const skills = Promise.withResolvers() + await h.run(async (client) => { + const { sessionId } = await start(client) + const session = h.host.sessions[0]! + session.listSkills.mockImplementation(() => skills.promise) + session.sendTurn.mockRejectedValue(new Error('backend stopped')) + const response = didRequestSucceed(prompt(client, sessionId)) + await until(() => session.listSkills.mock.calls.length === 1) + h.host.exit('backend stopped') + skills.resolve([]) + expect(await response).toBe(false) + expect(session.sendTurn).not.toHaveBeenCalled() + expect(session.dispose).toHaveBeenCalledTimes(1) + }) + }) + + it.each(['close', 'backend exit'])( + 'refuses a load whose backend response arrives after %s', + async (ending) => { + const h = harness() + const resumed = Promise.withResolvers>>() + const resume = h.host.resumeSession.getMockImplementation()! + let held: Awaited> | undefined + h.host.resumeSession.mockImplementationOnce(async (...args) => { + held = await resume(...args) + return await resumed.promise + }) + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + const loading = didRequestSucceed( + client.request('session/resume', { sessionId: 'old-1', cwd: CWD }), + ) + await until(() => held !== undefined) + if (ending === 'close') { + await client.request('session/close', { sessionId: 'old-1' }) + } else { + h.host.exit('backend stopped') + } + resumed.resolve(held!) + expect(await loading).toBe(false) + await expect(prompt(client, 'old-1')).rejects.toThrow() + }) + expect(h.host.sessions[0]?.dispose).toHaveBeenCalledTimes(1) + }, + ) + + it('forgets a failed resume before a later close', async () => { + const h = harness() + h.host.resumeSession.mockRejectedValueOnce(new Error('no session')) + await h.run(async (client) => { + await client.request('initialize', { protocolVersion: acp.PROTOCOL_VERSION }) + await expect( + client.request('session/resume', { sessionId: 'old-1', cwd: CWD }), + ).rejects.toThrow() + await expect(client.request('session/close', { sessionId: 'old-1' })).rejects.toThrow() + }) + }) +}) diff --git a/test/unit/acpModelApi.test.ts b/test/unit/acpModelApi.test.ts new file mode 100644 index 00000000..862c8cbe --- /dev/null +++ b/test/unit/acpModelApi.test.ts @@ -0,0 +1,382 @@ +import * as acp from '@agentclientprotocol/sdk' +import { mkdtempSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { createAcpAgent } from '../../src/acp/agent' +import { createRuntimeBackend } from '../../src/runtime/backends' +import { paidGrantsFile } from '../../src/runtime/dataFolder' +import { paidGrantFile } from '../../src/runtime/paidGrants' +import { type AcpPaidFeature, SECRET_KEYS, UI_TEXT } from '../../src/shared/constants' +import { memorySecrets } from './helpers/fakes' +import { fakeModelApi } from './helpers/fakeModelApi' +import { buildModelApiBundle } from './helpers/modelApiBundle' +import { removeFolder } from './helpers/temporaryFolders' + +// M63 (PLAN.md D61, D62): the agent on the Model API backend, end to end in +// process: the ACP SDK's client, the agent, the runtime's backend with its +// real tool harness and session store on disk, the backend loaded from a +// built dist/modelApi.js as the package ships it (M57, D6), and the fake +// Model API. The stdio suite cannot run this backend, because the agent +// reads the key only from the OS credential store. + +// The one key the fake Model API accepts. +const KEY = 'LLM|1|secret' +const POLL_MS = 5 +const WAIT_MS = 5000 +const folders: string[] = [] +const dist = { folder: '' } + +function folder(): string { + const created = mkdtempSync(path.join(tmpdir(), 'acp-model-api-')) + folders.push(created) + return created +} + +// The agent's dist/ folder: the Model API backend's own bundle, required by path. +beforeAll(() => { + dist.folder = folder() + buildModelApiBundle(dist.folder) +}) + +afterAll(async () => { + await Promise.all(folders.map((created) => removeFolder(created))) +}) + +async function until(isMet: () => boolean | Promise): Promise { + const deadline = Date.now() + WAIT_MS + while (!(await isMet())) { + if (Date.now() > deadline) { + throw new Error('condition not met in time') + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)) + } +} + +function writeCall(file: string, content: string, callId: string) { + return { name: 'write_file', arguments: JSON.stringify({ path: file, content }), callId } +} + +function setup( + answer: (request: acp.RequestPermissionRequest) => acp.RequestPermissionResponse, + paidFeatures: readonly AcpPaidFeature[] = [], + isTrusted = false, + // A later agent on the same computer and folder shares these. + shared?: { readonly data: string; readonly workspace: string }, + // More of the agent's own environment (a shell tool needs PATH). + extraEnv: NodeJS.ProcessEnv = {}, +) { + const api = fakeModelApi() + const secrets = memorySecrets() + secrets.values.set(SECRET_KEYS.modelApiKey, KEY) + const { data, workspace } = shared ?? { data: folder(), workspace: folder() } + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + const runtime = createRuntimeBackend({ + options: { + backend: 'modelApi', + trustWorkspace: isTrusted, + museBinary: '', + shellSandbox: 'auto', + canBypass: false, + allowsContributorModels: false, + paidFeatures, + isVerbose: false, + }, + version: '0.0.0-test', + distDir: dist.folder, + platform: process.platform, + env: { ...extraEnv, XDG_DATA_HOME: data, LOCALAPPDATA: data }, + homeDir: data, + secrets, + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [], + fetch: api.fetch, + sleep: () => Promise.resolve(), + log, + }) + const agent = createAcpAgent({ + backend: runtime.backend, + version: '0.0.0-test', + options: { canBypass: false, allowsContributorModels: false, initialMode: 'manual' }, + signIn: { + id: 'model-api-key', + name: 'Store a key', + description: 'Store a key', + args: ['auth', 'set'], + command: 'muse-spark-code-acp auth set', + }, + defaultCwd: workspace, + paid: runtime.paid, + log, + }) + const updates: acp.SessionUpdate[] = [] + const permissions: acp.RequestPermissionRequest[] = [] + const client = acp + .client({ name: 'test-client' }) + .onNotification('session/update', (context) => { + updates.push(context.params.update) + }) + .onRequest('session/request_permission', (context) => { + permissions.push(context.params) + return answer(context.params) + }) + return { + api, + log, + data, + workspace, + runtime, + updates, + permissions, + run: (op: (context: acp.ClientContext) => Promise) => client.connectWith(agent, op), + } +} + +function allowOnce(request: acp.RequestPermissionRequest): acp.RequestPermissionResponse { + const option = request.options.find((candidate) => candidate.kind === 'allow_once') + return { + outcome: + option === undefined + ? { outcome: 'cancelled' } + : { outcome: 'selected', optionId: option.optionId }, + } +} + +/** Answers a paid-use question with `optionId` (M58), and anything else with an unknown option. */ +function answerPaid(optionId: string) { + return (request: acp.RequestPermissionRequest): acp.RequestPermissionResponse => ({ + outcome: { + outcome: 'selected', + optionId: request.options.some((option) => option.optionId === optionId) + ? optionId + : 'reject', + }, + }) +} + +type MessageChunk = Extract< + acp.SessionUpdate, + { sessionUpdate: 'user_message_chunk' | 'agent_message_chunk' } +> + +function textOf( + updates: readonly acp.SessionUpdate[], + kind: MessageChunk['sessionUpdate'], +): string { + return updates + .filter((update): update is MessageChunk => update.sessionUpdate === kind) + .map((update) => (update.content.type === 'text' ? update.content.text : '')) + .join('') +} + +async function initialize(client: acp.ClientContext): Promise { + await client.request('initialize', { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }) +} + +/** A new session in the folder, asked to write the notes. */ +async function promptOnce(client: acp.ClientContext, cwd: string) { + await initialize(client) + const created = await client.request('session/new', { cwd, mcpServers: [] }) + const response = await client.request('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'write the notes' }], + }) + return { created, stopReason: response.stopReason } +} + +/** A second prompt in the session. */ +async function promptAgain(client: acp.ClientContext, sessionId: string) { + return await client.request('session/prompt', { + sessionId, + prompt: [{ type: 'text', text: 'and again' }], + }) +} + +describe('the ACP agent on the Model API backend (M63)', () => { + it('writes a file the client allowed, streams the reply, and never sends the key to the client', async () => { + const t = setup(allowOnce) + t.api.script({ calls: [writeCall('notes.txt', 'hello\n', 'c1')] }, { text: 'Wrote it.' }) + const { created, stopReason } = await t.run((client) => promptOnce(client, t.workspace)) + expect(created.configOptions?.map((option) => option.id)).toEqual(['model', 'effort']) + expect(stopReason).toBe('end_turn') + expect(readFileSync(path.join(t.workspace, 'notes.txt'), 'utf8')).toBe('hello\n') + expect(t.permissions).toHaveLength(1) + expect(t.permissions[0]?.toolCall.kind).toBe('edit') + const done = t.updates.find( + (update) => update.sessionUpdate === 'tool_call_update' && update.status === 'completed', + ) + expect(done).toBeDefined() + expect(textOf(t.updates, 'agent_message_chunk')).toBe('Wrote it.') + // The key went to the Model API as the bearer token, and nowhere near the client. + expect(t.api.requests.at(-1)?.headers['Authorization']).toBe(`Bearer ${KEY}`) + expect(JSON.stringify([t.updates, t.permissions])).not.toContain(KEY) + // Paid features are off without their flags (M63c): no web search or image tools offered. + const offered = JSON.stringify(t.api.responseBodies()[0]?.['tools']) + for (const paid of ['web_search', 'generate_image', 'edit_image']) { + expect(offered).not.toContain(paid) + } + expect(offered).toContain('write_file') + await t.runtime.close() + }) + + it('writes nothing the client cancelled, then lists and loads the session from disk', async () => { + const t = setup(() => ({ outcome: { outcome: 'cancelled' } })) + t.api.script({ calls: [writeCall('notes.txt', 'x', 'c1')] }, { text: 'Left it alone.' }) + const { created, stopReason } = await t.run((client) => promptOnce(client, t.workspace)) + const { sessionId } = created + expect(stopReason).toBe('end_turn') + expect(existsSync(path.join(t.workspace, 'notes.txt'))).toBe(false) + const failed = t.updates.find( + (update) => update.sessionUpdate === 'tool_call_update' && update.status === 'failed', + ) + expect(failed).toBeDefined() + + t.updates.length = 0 + await t.run(async (client) => { + await initialize(client) + await until(async () => { + const listed = await client.request('session/list', { cwd: t.workspace }) + return listed.sessions.some((session) => session.sessionId === sessionId) + }) + await client.request('session/load', { sessionId, cwd: t.workspace, mcpServers: [] }) + }) + expect(textOf(t.updates, 'user_message_chunk')).toBe('write the notes') + expect(textOf(t.updates, 'agent_message_chunk')).toBe('Left it alone.') + await t.runtime.close() + }) + + it('offers Muse Code’s memory tools in a trusted folder, and never paid subagents (M48, M49)', async () => { + const t = setup(allowOnce, [], true) + t.api.script({ text: 'Noted.' }) + await t.run((client) => promptOnce(client, t.workspace)) + const offered = JSON.stringify(t.api.responseBodies()[0]?.['tools']) + for (const memory of ['read_memory', 'add_memory', 'edit_memory']) { + expect(offered).toContain(memory) + } + expect(offered).not.toContain('subagent_spawn') + await t.runtime.close() + }) + + it('asks before each prompt that may search the web, and tallies each search (M58)', async () => { + const t = setup(answerPaid('paid-allow-once'), ['webSearch']) + t.api.script( + { searches: [{ queries: ['acp registry'] }], text: 'Found it.' }, + { text: 'Nothing to search.' }, + ) + await t.run(async (client) => { + const { created } = await promptOnce(client, t.workspace) + await promptAgain(client, created.sessionId) + }) + expect(t.permissions.map((request) => request.toolCall.title)).toEqual([ + 'Let Muse search the web for this prompt?', + 'Let Muse search the web for this prompt?', + ]) + for (const body of t.api.responseBodies()) { + expect(JSON.stringify(body['tools'])).toContain('web_search') + } + expect(t.log.info).toHaveBeenCalledWith('Paid use of webSearch: 1, 1 since the agent started') + await t.runtime.close() + }) + + it('sends the prompt without web search when its question is denied (M58)', async () => { + const t = setup(answerPaid('paid-deny'), ['webSearch']) + t.api.script({ text: 'No search.' }) + await t.run((client) => promptOnce(client, t.workspace)) + expect(t.permissions).toHaveLength(1) + expect(JSON.stringify(t.api.responseBodies()[0]?.['tools'])).not.toContain('web_search') + await t.runtime.close() + }) + + it('says what to set in the agent’s environment when Meta cannot be reached (Q66)', async () => { + const t = setup(allowOnce) + t.api.script({ networkError: 'fetch failed', networkErrorCode: 'ECONNREFUSED' }) + let failure = 'the prompt succeeded' + try { + await t.run((client) => promptOnce(client, t.workspace)) + } catch (error: unknown) { + failure = error instanceof Error ? error.message : String(error) + } + expect(failure).toContain(UI_TEXT.acpNetworkUnreachable) + expect(failure).toContain('NODE_USE_ENV_PROXY=1') + expect(failure).not.toContain('http.proxy') + await t.runtime.close() + }) + + it( + 'runs a shell command with no credential variable in its environment (Codex on a209130)', + { timeout: 60_000 }, + async () => { + // Trusted: shell commands run only in a trusted folder. + const t = setup(allowOnce, [], true, undefined, { + ...process.env, + META_API_KEY: 'LLM|1|placeholder', + EXAMPLE_API_KEY: 'placeholder-too', + }) + const shell = process.platform === 'win32' ? 'powershell' : 'bash' + // Only a real run prints the joined word; the command's own text does not hold it. + const command = `node -e "console.log('keys' + '-' + ([process.env.META_API_KEY, process.env.EXAMPLE_API_KEY].join('') || 'none'))"` + t.api.script( + { + calls: [ + { + name: shell, + arguments: JSON.stringify({ command, description: 'keys' }), + callId: 'k1', + }, + ], + }, + { text: 'Checked.' }, + ) + await t.run((client) => promptOnce(client, t.workspace)) + const sent = JSON.stringify(t.api.responseBodies()[1]) + expect(sent).toContain('keys-none') + expect(sent).not.toContain('placeholder') + await t.runtime.close() + }, + ) + + it('keeps "Allow always" for a trusted folder until the agent starts without the flag (M58)', async () => { + const first = setup(answerPaid('paid-allow-always'), ['webSearch'], true) + first.api.script({ text: 'One.' }, { text: 'Two.' }) + await first.run(async (client) => { + const { created } = await promptOnce(client, first.workspace) + await promptAgain(client, created.sessionId) + }) + expect(first.permissions).toHaveLength(1) + const file = paidGrantsFile({ + platform: process.platform, + env: { XDG_DATA_HOME: first.data, LOCALAPPDATA: first.data }, + homeDir: first.data, + }) + const grants = paidGrantFile({ file, log: first.log, sleep: () => Promise.resolve() }) + expect(grants.read(first.workspace)).toEqual(new Set(['webSearch'])) + await first.runtime.close() + + // Started again with the flag, the folder still asks nothing. + const again = setup(answerPaid('paid-deny'), ['webSearch'], true, first) + await again.runtime.forgetUnflaggedGrants() + again.api.script({ text: 'Three.' }) + await again.run((client) => promptOnce(client, again.workspace)) + expect(again.permissions).toEqual([]) + expect(JSON.stringify(again.api.responseBodies()[0]?.['tools'])).toContain('web_search') + await again.runtime.close() + + // Started without it, the grant lapses, so with it again the folder asks again. + const without = setup(answerPaid('paid-deny'), [], true, first) + await without.runtime.forgetUnflaggedGrants() + expect(grants.read(first.workspace)).toEqual(new Set()) + await without.runtime.close() + const flaggedAgain = setup(answerPaid('paid-deny'), ['webSearch'], true, first) + await flaggedAgain.runtime.forgetUnflaggedGrants() + flaggedAgain.api.script({ text: 'Four.' }) + await flaggedAgain.run((client) => promptOnce(client, flaggedAgain.workspace)) + expect(flaggedAgain.permissions).toHaveLength(1) + expect(JSON.stringify(flaggedAgain.api.responseBodies()[0]?.['tools'])).not.toContain( + 'web_search', + ) + await flaggedAgain.runtime.close() + }) +}) diff --git a/test/unit/acpPaid.test.ts b/test/unit/acpPaid.test.ts new file mode 100644 index 00000000..96ece4ed --- /dev/null +++ b/test/unit/acpPaid.test.ts @@ -0,0 +1,475 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import type * as Fs from 'node:fs' +import { link, rename } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AcpPaidUse, paidUseAnswer, paidUseOptions } from '../../src/acp/paid' +import { paidGrantFile } from '../../src/runtime/paidGrants' +import { workspaceKey } from '../../src/runtime/dataFolder' +import { memoryPaidGrants } from './helpers/paidGrants' +import { removeFolder } from './helpers/temporaryFolders' + +// M58 in the agent (PLAN.md D48, D62): a paid use is off without its flag, +// asks the editor each time otherwise, and "Allow always" is kept per folder +// in the agent's data folder and forgotten when the agent starts without the +// flag. + +const FOLDER = path.resolve('work', 'app') +const OTHER = path.resolve('work', 'other') +const WEB_SEARCH = { feature: 'webSearch' } as const +const folders: string[] = [] + +vi.mock('node:fs', async (importActual) => { + const actual = await importActual() + return { ...actual, readFileSync: vi.fn(actual.readFileSync) } +}) +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual() + return { ...actual, link: vi.fn(actual.link), rename: vi.fn(actual.rename) } +}) +const actualFs = await vi.importActual('node:fs/promises') +const actualFsSync = await vi.importActual('node:fs') + +function restoreFileIo(): void { + vi.mocked(readFileSync).mockImplementation(actualFsSync.readFileSync) + vi.mocked(link).mockImplementation(actualFs.link) + vi.mocked(rename).mockImplementation(actualFs.rename) +} + +beforeEach(restoreFileIo) +afterEach(restoreFileIo) + +afterAll(async () => { + await Promise.all(folders.map((folder) => removeFolder(folder))) +}) + +function logger() { + return { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } +} + +function grantsFile(): string { + // Existing atomic-write targets use their real path, including macOS's + // /private/var and Windows junctions and 8.3 names. The native API expands + // short names too, matching fs.promises.realpath in the atomic writer. + const folder = realpathSync.native(mkdtempSync(path.join(tmpdir(), 'acp-paid-grants-'))) + folders.push(folder) + return path.join(folder, 'acp', 'paid-uses.json') +} + +/** The client chose the option of that id. */ +function selected(optionId: string) { + return { outcome: { outcome: 'selected' as const, optionId } } +} + +function fileStore(file: string, log = logger()) { + return paidGrantFile({ file, log, sleep: () => Promise.resolve() }) +} + +/** Two processes starting from one explicit web-search grant. */ +async function grantedStores() { + const file = grantsFile() + const first = fileStore(file) + await first.add(FOLDER, ['webSearch']) + return { file, first, second: fileStore(file) } +} + +function generationPath(file: string, feature = 'webSearch'): string { + return path.join(`${file}.d`, feature, 'generation.json') +} + +function grantPath(file: string, feature = 'webSearch', workspaceRoot = FOLDER): string { + const generation: unknown = JSON.parse(readFileSync(generationPath(file, feature), 'utf8')) + if ( + typeof generation !== 'object' || + generation === null || + !('id' in generation) || + typeof generation.id !== 'string' + ) { + throw new Error('no generation for the grant') + } + return path.join(`${file}.d`, feature, `${workspaceKey(workspaceRoot)}.${generation.id}.json`) +} + +/** Holds one selected write so another process can change the store before it lands. */ +function holdFirstRename(isHolding: (from: Fs.PathLike, to: Fs.PathLike) => boolean) { + const held = Promise.withResolvers() + const released = Promise.withResolvers() + let hasHeld = false + vi.mocked(rename).mockImplementation(async (from, to) => { + if (!hasHeld && isHolding(from, to)) { + hasHeld = true + held.resolve(undefined) + await released.promise + } + await actualFs.rename(from, to) + }) + return { held, released } +} + +describe('AcpPaidUse', () => { + it('denies every use until the agent attaches its way to ask', async () => { + const log = logger() + const paid = new AcpPaidUse({ + flagged: ['webSearch'], + canRemember: () => true, + grants: memoryPaidGrants(), + log, + }) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(false) + expect(log.warn).toHaveBeenCalledWith( + 'Paid use of webSearch: no editor to ask, so it is denied', + ) + const asker = vi.fn(() => Promise.reject(new Error('the connection closed'))) + paid.attach(asker) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(false) + expect(asker).toHaveBeenCalledWith('s1', WEB_SEARCH, true) + expect(log.warn).toHaveBeenCalledWith( + 'Paid use of webSearch: the editor could not be asked, so it is denied: Error', + ) + }) + + it('keeps "always" per folder, asks again where a hook demands it, and not without trust', async () => { + const grants = memoryPaidGrants() + let isTrusted = true + const paid = new AcpPaidUse({ + flagged: ['webSearch'], + canRemember: () => isTrusted, + grants, + log: logger(), + }) + const asker = vi.fn(() => Promise.resolve('always' as const)) + paid.attach(asker) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(true) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(true) + expect(asker).toHaveBeenCalledTimes(1) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, true)).toBe(true) + expect(await paid.allows(OTHER, 's2', WEB_SEARCH, false)).toBe(true) + expect(asker).toHaveBeenCalledTimes(3) + expect(asker).toHaveBeenLastCalledWith('s2', WEB_SEARCH, true) + isTrusted = false + expect(paid.isRemembered(FOLDER, 'webSearch')).toBe(false) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(true) + expect(asker).toHaveBeenLastCalledWith('s1', WEB_SEARCH, false) + }) + + it('forgets "always" for every feature it starts without', async () => { + const grants = memoryPaidGrants() + grants.byFolder.set(FOLDER, new Set(['webSearch', 'imageGeneration'])) + const paid = new AcpPaidUse({ + flagged: ['imageGeneration'], + canRemember: () => true, + grants, + log: logger(), + }) + expect(paid.isRemembered(FOLDER, 'webSearch')).toBe(false) + await paid.forgetUnflagged() + expect(grants.byFolder.get(FOLDER)).toEqual(new Set(['imageGeneration'])) + expect(paid.isRemembered(FOLDER, 'imageGeneration')).toBe(true) + }) + + it('logs a grants file it cannot write, and still honours nothing unflagged', async () => { + const grants = memoryPaidGrants() + grants.byFolder.set(FOLDER, new Set(['webSearch'])) + vi.spyOn(grants, 'forget').mockRejectedValue(new Error('read-only data folder')) + const log = logger() + const paid = new AcpPaidUse({ flagged: [], canRemember: () => true, grants, log }) + await paid.forgetUnflagged() + expect(log.warn).toHaveBeenCalledWith( + 'Paid uses allowed always could not be forgotten for webSearch, imageGeneration: read-only data folder', + ) + expect(paid.isRemembered(FOLDER, 'webSearch')).toBe(false) + }) + + it('lets an "always" it cannot keep go ahead once, and asks again next time', async () => { + const grants = memoryPaidGrants() + vi.spyOn(grants, 'add').mockRejectedValue(new Error('read-only data folder')) + const log = logger() + const paid = new AcpPaidUse({ flagged: ['webSearch'], canRemember: () => true, grants, log }) + const asker = vi.fn(() => Promise.resolve('always' as const)) + paid.attach(asker) + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(true) + expect(log.warn).toHaveBeenCalledWith( + 'Paid use of webSearch: "always" could not be kept, so it is allowed once: read-only data folder', + ) + expect(log.info).toHaveBeenLastCalledWith('Paid use of webSearch: allowed once') + expect(await paid.allows(FOLDER, 's1', WEB_SEARCH, false)).toBe(true) + expect(asker).toHaveBeenCalledTimes(2) + }) + + it('leaves the grants alone when every feature is flagged', async () => { + const grants = memoryPaidGrants() + const forget = vi.spyOn(grants, 'forget') + const paid = new AcpPaidUse({ + flagged: ['webSearch', 'imageGeneration'], + canRemember: () => true, + grants, + log: logger(), + }) + await paid.forgetUnflagged() + expect(forget).not.toHaveBeenCalled() + }) + + it('tallies billed uses in the log', () => { + const log = logger() + const paid = new AcpPaidUse({ + flagged: ['imageGeneration'], + canRemember: () => false, + grants: memoryPaidGrants(), + log, + }) + paid.noteUse('imageGeneration', 1) + paid.noteUse('imageGeneration', 2) + expect(log.info).toHaveBeenLastCalledWith( + 'Paid use of imageGeneration: 2, 3 since the agent started', + ) + }) +}) + +describe('the paid-use prompt’s options and answers', () => { + it('offers "always" only where it is kept', () => { + expect(paidUseOptions(true).map((option) => option.optionId)).toEqual([ + 'paid-allow-once', + 'paid-allow-always', + 'paid-deny', + ]) + expect(paidUseOptions(false).map((option) => option.optionId)).toEqual([ + 'paid-allow-once', + 'paid-deny', + ]) + }) + + it('reads anything but a chosen allow as Deny', () => { + expect(paidUseAnswer(selected('paid-allow-once'), false)).toBe('once') + expect(paidUseAnswer(selected('paid-allow-always'), true)).toBe('always') + expect(paidUseAnswer(selected('paid-allow-always'), false)).toBe('deny') + expect(paidUseAnswer(selected('paid-deny'), true)).toBe('deny') + expect(paidUseAnswer({ outcome: { outcome: 'cancelled' } }, true)).toBe('deny') + }) +}) + +describe('the grants file (runtime/paidGrants.ts)', () => { + it('never restores a revoked grant when another process finishes its older addition', async () => { + const { file, first, second } = await grantedStores() + // The old store rewrites its whole map; the corrected store writes only this grant. + const { held, released } = holdFirstRename( + (from, to) => + (path.dirname(String(to)) === path.join(`${file}.d`, 'imageGeneration') && + path.basename(String(to)).startsWith(`${workspaceKey(OTHER)}.`)) || + (String(to) === file && readFileSync(from, 'utf8').includes('imageGeneration')), + ) + const adding = second.add(OTHER, ['imageGeneration']) + try { + await held.promise + await first.forget(['webSearch']) + expect(first.read(FOLDER)).toEqual(new Set()) + } finally { + released.resolve(undefined) + } + await adding + expect(first.read(FOLDER)).toEqual(new Set()) + expect(first.read(OTHER)).toEqual(new Set(['imageGeneration'])) + }) + + it('reads nothing before the first grant, then each folder’s own, from any process', async () => { + const file = grantsFile() + const store = fileStore(file) + expect(store.read(FOLDER)).toEqual(new Set()) + await store.add(FOLDER, ['webSearch']) + await store.add(OTHER, ['imageGeneration']) + // Another process's store over the same file sees them at once. + const other = fileStore(file) + expect(other.read(FOLDER)).toEqual(new Set(['webSearch'])) + expect(other.read(OTHER)).toEqual(new Set(['imageGeneration'])) + // Folder hashes and opaque generations, never paths or prompt content. + const saved: unknown = JSON.parse(readFileSync(grantPath(file), 'utf8')) + expect(saved).toEqual(expect.stringMatching(/^[\da-f-]{36}$/)) + expect(readFileSync(generationPath(file), 'utf8')).not.toContain('work') + }) + + it('adds to the file as it is when written, never a set read before another change', async () => { + const file = grantsFile() + const store = fileStore(file) + const other = fileStore(file) + // Two sessions of one agent, each answering "always" for a different feature. + await Promise.all([store.add(FOLDER, ['webSearch']), store.add(FOLDER, ['imageGeneration'])]) + expect(store.read(FOLDER)).toEqual(new Set(['webSearch', 'imageGeneration'])) + // A feature another agent forgot is not written back by a later add. + await other.forget(['webSearch']) + await store.add(FOLDER, ['imageGeneration']) + expect(store.read(FOLDER)).toEqual(new Set(['imageGeneration'])) + }) + + it('fails a change on a file it cannot read, rather than writing over it, and reads it as none', async () => { + const file = grantsFile() + const log = logger() + const store = fileStore(file, log) + // A folder where the generation should be: there, and unreadable as a file. + mkdirSync(generationPath(file), { recursive: true }) + expect(store.read(FOLDER)).toEqual(new Set()) + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Paid-use grants in')) + await expect(store.add(FOLDER, ['webSearch'])).rejects.toThrow('could not be read') + await expect(store.forget(['webSearch'])).rejects.toThrow() + }) + + it('writes one change at a time, each on the file as it then is', async () => { + const file = grantsFile() + const store = fileStore(file) + await Promise.all([store.add(FOLDER, ['webSearch']), store.add(OTHER, ['webSearch'])]) + expect(store.read(FOLDER)).toEqual(new Set(['webSearch'])) + expect(store.read(OTHER)).toEqual(new Set(['webSearch'])) + }) + + it('revokes a feature in every folder while preserving other features', async () => { + const file = grantsFile() + const store = fileStore(file) + await store.add(FOLDER, ['webSearch', 'imageGeneration']) + await store.add(OTHER, ['webSearch']) + await store.forget(['webSearch']) + expect(store.read(FOLDER)).toEqual(new Set(['imageGeneration'])) + expect(store.read(OTHER)).toEqual(new Set()) + await store.forget(['webSearch']) + expect(store.read(FOLDER)).toEqual(new Set(['imageGeneration'])) + await store.add(OTHER, ['webSearch']) + expect(store.read(OTHER)).toEqual(new Set(['webSearch'])) + expect(store.read(FOLDER)).toEqual(new Set(['imageGeneration'])) + }) + + it('ignores legacy grants and fails closed for damaged or missing records', async () => { + const file = grantsFile() + const log = logger() + const store = fileStore(file, log) + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify({ [workspaceKey(FOLDER)]: ['webSearch', 'everything'] })) + expect(store.read(FOLDER)).toEqual(new Set()) + await store.add(FOLDER, ['webSearch']) + writeFileSync(generationPath(file), '{"half":') + expect(store.read(FOLDER)).toEqual(new Set()) + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Paid-use grants in')) + await expect(store.add(FOLDER, ['webSearch'])).rejects.toThrow('could not be read') + await store.forget(['webSearch']) + await store.add(FOLDER, ['webSearch']) + writeFileSync(grantPath(file), '["webSearch"]') + expect(store.read(FOLDER)).toEqual(new Set()) + await store.add(FOLDER, ['webSearch']) + rmSync(generationPath(file)) + expect(store.read(FOLDER)).toEqual(new Set()) + await store.add(OTHER, ['webSearch']) + // A fresh generation never revives a grant whose generation went missing. + expect(store.read(FOLDER)).toEqual(new Set()) + expect(store.read(OTHER)).toEqual(new Set(['webSearch'])) + }) + + it('preserves independent concurrent grants from multiple processes, including their first writes', async () => { + const file = grantsFile() + const first = fileStore(file) + const second = fileStore(file) + await Promise.all([ + first.add(FOLDER, ['webSearch']), + second.add(OTHER, ['webSearch', 'imageGeneration']), + fileStore(file).add(FOLDER, ['imageGeneration']), + ]) + expect(first.read(FOLDER)).toEqual(new Set(['webSearch', 'imageGeneration'])) + expect(second.read(OTHER)).toEqual(new Set(['webSearch', 'imageGeneration'])) + }) + + it('rejects a grant whose generation changes while it is read', async () => { + const file = grantsFile() + const store = fileStore(file) + await store.add(FOLDER, ['webSearch']) + const target = grantPath(file) + vi.mocked(readFileSync).mockImplementation((...args) => { + const text = actualFsSync.readFileSync(...args) + if (String(args[0]) === target) { + writeFileSync( + generationPath(file), + JSON.stringify({ id: '3ebd9e69-fc07-441a-ac7a-f1387ac2d8cc', isInitial: false }), + ) + } + return text + }) + expect(store.read(FOLDER)).toEqual(new Set()) + }) + + it('does not authorize a grant that finishes after its feature was revoked', async () => { + const { file, first, second } = await grantedStores() + const { held, released } = holdFirstRename( + (_from, to) => String(to) === grantPath(file, 'webSearch', OTHER), + ) + const adding = second.add(OTHER, ['webSearch']) + try { + await held.promise + await first.forget(['webSearch']) + } finally { + released.resolve(undefined) + } + await adding + expect(first.read(FOLDER)).toEqual(new Set()) + expect(second.read(OTHER)).toEqual(new Set()) + }) + + it('does not join a revocation that wins its first-generation publication race', async () => { + const file = grantsFile() + const held = Promise.withResolvers() + const released = Promise.withResolvers() + vi.mocked(link).mockImplementation(async (from, to) => { + held.resolve(undefined) + await released.promise + await actualFs.link(from, to) + }) + const first = fileStore(file) + const adding = first.add(FOLDER, ['webSearch']) + const failed = expect(adding).rejects.toThrow('revoked while this grant was being initialized') + try { + await held.promise + await fileStore(file).forget(['webSearch']) + } finally { + released.resolve(undefined) + } + await failed + expect(first.read(FOLDER)).toEqual(new Set()) + await first.add(OTHER, ['webSearch']) + expect(first.read(OTHER)).toEqual(new Set(['webSearch'])) + }) + + it('preserves a newer explicit grant when an old-generation writer finishes last', async () => { + const { file, first, second } = await grantedStores() + const { held, released } = holdFirstRename( + (_from, to) => + path.dirname(String(to)) === path.join(`${file}.d`, 'webSearch') && + path.basename(String(to)).startsWith(`${workspaceKey(FOLDER)}.`), + ) + const older = second.add(FOLDER, ['webSearch']) + try { + await held.promise + await first.forget(['webSearch']) + expect(first.read(FOLDER)).toEqual(new Set()) + await first.add(FOLDER, ['webSearch']) + expect(first.read(FOLDER)).toEqual(new Set(['webSearch'])) + } finally { + released.resolve(undefined) + } + await older + expect(second.read(FOLDER)).toEqual(new Set(['webSearch'])) + }) + + it('grants nothing when publication fails, and permits a later explicit grant', async () => { + const file = grantsFile() + const store = fileStore(file) + vi.mocked(link).mockRejectedValueOnce(new Error('hard links unavailable')) + await expect(store.add(FOLDER, ['webSearch'])).rejects.toThrow('hard links unavailable') + expect(store.read(FOLDER)).toEqual(new Set()) + await store.add(FOLDER, ['webSearch']) + expect(store.read(FOLDER)).toEqual(new Set(['webSearch'])) + vi.mocked(rename).mockImplementation(async (from, to) => { + if (String(to) === grantPath(file, 'webSearch', OTHER)) { + throw new Error('grant replacement failed') + } + await actualFs.rename(from, to) + }) + await expect(store.add(OTHER, ['webSearch'])).rejects.toThrow('grant replacement failed') + expect(store.read(OTHER)).toEqual(new Set()) + expect(store.read(FOLDER)).toEqual(new Set(['webSearch'])) + }) +}) diff --git a/test/unit/acpProxyWarning.test.ts b/test/unit/acpProxyWarning.test.ts new file mode 100644 index 00000000..970bbe27 --- /dev/null +++ b/test/unit/acpProxyWarning.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { type EnvProxyInput, envProxyWarning } from '../../src/runtime/proxyWarning' + +// PLAN.md Q66: the agent says once, at start, when a proxy variable is set +// but the Model API backend's requests will not use it: Node's switch off, +// or a Node without the switch. The versions and the switch's spellings are +// the ones measured against a local proxy (pr32-integration.md). + +const PROXY = 'http://user:secret@127.0.0.1:8080' + +function warning(overrides: Partial = {}): string | undefined { + return envProxyWarning({ + backend: 'modelApi', + platform: 'linux', + env: { HTTPS_PROXY: PROXY }, + execArgv: [], + nodeVersion: 'v22.23.3', + ...overrides, + }) +} + +describe('envProxyWarning', () => { + it('says nothing without a proxy variable, or on the Muse Code backend', () => { + expect(warning({ env: {} })).toBeUndefined() + expect(warning({ env: { HTTPS_PROXY: '' } })).toBeUndefined() + // ALL_PROXY is not one Node's switch reads; Muse Code reads it itself. + expect(warning({ env: { ALL_PROXY: PROXY } })).toBeUndefined() + expect(warning({ backend: 'museCode' })).toBeUndefined() + }) + + it.each(['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'])( + 'warns for %s set with the switch off, naming the variable and not its value', + (name) => { + const said = warning({ env: { [name]: PROXY } }) + expect(said).toContain(`${name} is set, but NODE_USE_ENV_PROXY is not 1`) + expect(said).toContain('go to Meta directly, bypassing the proxy') + expect(said).toContain('Set NODE_USE_ENV_PROXY=1') + expect(said).not.toContain('127.0.0.1') + expect(said).not.toContain('secret') + }, + ) + + it('names each variable set once: both spellings on Linux, one on Windows, where they are one', () => { + const both = { HTTPS_PROXY: PROXY, https_proxy: PROXY, HTTP_PROXY: PROXY } + expect(warning({ env: both })).toMatch(/^HTTPS_PROXY, https_proxy, HTTP_PROXY are set, but/) + expect(warning({ platform: 'win32', env: both })).toMatch( + /^HTTPS_PROXY, HTTP_PROXY are set, but/, + ) + }) + + it('says nothing once the switch is on: the variable, the flag, or the flag in NODE_OPTIONS', () => { + expect(warning({ env: { HTTPS_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' } })).toBeUndefined() + expect(warning({ execArgv: ['--use-env-proxy'] })).toBeUndefined() + expect( + warning({ + env: { HTTPS_PROXY: PROXY, NODE_OPTIONS: '--max-old-space-size=4096 --use-env-proxy' }, + }), + ).toBeUndefined() + expect( + warning({ nodeVersion: 'v24.0.0', env: { HTTPS_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' } }), + ).toBeUndefined() + }) + + it('still warns for a switch Node does not take: only "1" turns it on', () => { + for (const value of ['true', '0', 'yes']) { + expect(warning({ env: { HTTPS_PROXY: PROXY, NODE_USE_ENV_PROXY: value } })).toContain( + 'NODE_USE_ENV_PROXY is not 1', + ) + } + expect( + warning({ env: { HTTPS_PROXY: PROXY, NODE_OPTIONS: '--use-env-proxy-x' } }), + ).toBeDefined() + }) + + it.each(['v22.0.0', 'v22.20.0', 'v23.11.1', 'v20.18.3', 'not a version'])( + 'warns that Node %s is too old for any proxy, even with the switch on', + (nodeVersion) => { + const said = warning({ + nodeVersion, + env: { HTTPS_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' }, + }) + expect(said).toContain(`Node ${nodeVersion} cannot send the Model API backend's requests`) + expect(said).toContain('Node 22.21 or later, or 24, can, with NODE_USE_ENV_PROXY=1') + }, + ) + + it.each(['v22.21.0', 'v22.23.3', 'v24.0.0', 'v24.20.0', 'v25.1.0'])( + 'takes Node %s as one with the switch', + (nodeVersion) => { + expect(warning({ nodeVersion })).toContain('NODE_USE_ENV_PROXY is not 1') + expect( + warning({ nodeVersion, env: { HTTPS_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' } }), + ).toBeUndefined() + }, + ) +}) diff --git a/test/unit/acpRuntime.test.ts b/test/unit/acpRuntime.test.ts new file mode 100644 index 00000000..453caec9 --- /dev/null +++ b/test/unit/acpRuntime.test.ts @@ -0,0 +1,653 @@ +import { EventEmitter } from 'node:events' +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { LaunchResolution } from '../../src/core/backends/musecode/launch' +import { authClear, authSet, authStatus, login } from '../../src/runtime/authCommands' +import { createRuntimeBackend } from '../../src/runtime/backends' +import { parseCommandLine, type ServeOptions } from '../../src/runtime/cliArgs' +import { takeCredentials, withoutCredentials } from '../../src/runtime/credentialVariables' +import { + agentDataFolder, + paidGrantsFile, + workspaceSessionsFolder, +} from '../../src/runtime/dataFolder' +import { walkFiles } from '../../src/runtime/fileWalk' +import { readSecretLine } from '../../src/runtime/hiddenInput' +import { + credentialStoreName, + type KeyringEntry, + keyringSecretStore, +} from '../../src/runtime/keyStore' +import { displayLanguage } from '../../src/runtime/locale' +import { paidGrantFile } from '../../src/runtime/paidGrants' +import { stderrLogger } from '../../src/runtime/stderrLog' +import { webReadable } from '../../src/runtime/webStreams' +import { SECRET_KEYS, UI_TEXT } from '../../src/shared/constants' +import { memorySecrets } from './helpers/fakes' +import { fakeModelApi } from './helpers/fakeModelApi' +import { buildModelApiBundle } from './helpers/modelApiBundle' +import { removeFolder } from './helpers/temporaryFolders' + +// M63 (PLAN.md D61, D62): the agent's process, sign-in commands and key store. + +const KEY = 'LLM|123456|secret-value' +const folders: string[] = [] + +function folder(): string { + const created = mkdtempSync(path.join(tmpdir(), 'acp-runtime-')) + folders.push(created) + return created +} + +afterAll(async () => { + await Promise.all(folders.map((created) => removeFolder(created))) +}) + +const DEFAULTS: ServeOptions = { + backend: 'museCode', + trustWorkspace: false, + museBinary: '', + shellSandbox: 'auto', + canBypass: false, + allowsContributorModels: false, + paidFeatures: [], + isVerbose: false, +} + +function fakeKeyring() { + const values = new Map() + const opened: string[] = [] + const open = (service: string, account: string): KeyringEntry => { + opened.push(`${service}/${account}`) + const id = `${service}/${account}` + return { + // The native binding reads a missing entry as null. + getPassword: () => Promise.resolve(values.get(id) ?? null), + setPassword: (password) => { + values.set(id, password) + return Promise.resolve() + }, + deletePassword: () => Promise.resolve(values.delete(id)), + } + } + return { values, opened, open } +} + +function deps(line: string, secrets = memorySecrets()) { + const printed: string[] = [] + const errors: string[] = [] + return { + printed, + errors, + secrets, + deps: { + secrets, + storeName: 'the test store', + readSecret: vi.fn(() => Promise.resolve(line)), + print: (text: string) => { + printed.push(text) + }, + printError: (text: string) => { + errors.push(text) + }, + }, + } +} + +function fakeChild(): EventEmitter { + return new EventEmitter() +} + +describe('parseCommandLine', () => { + it('serves by default, with the flags as options', () => { + expect(parseCommandLine([])).toEqual({ command: 'serve', options: DEFAULTS }) + expect( + parseCommandLine([ + '--backend', + 'modelApi', + '--trust-workspace', + '--muse-binary=/opt/muse', + '--shell-sandbox', + 'off', + '--allow-dangerously-skip-permissions', + '--allow-contributor-models', + '--image-generation', + '--web-search', + '--verbose', + ]), + ).toEqual({ + command: 'serve', + options: { + backend: 'modelApi', + trustWorkspace: true, + museBinary: '/opt/muse', + shellSandbox: 'off', + canBypass: true, + allowsContributorModels: true, + paidFeatures: ['webSearch', 'imageGeneration'], + isVerbose: true, + }, + }) + }) + + it('refuses a paid feature on the Muse Code backend, naming the flag (M63c)', () => { + expect(parseCommandLine(['--web-search'])).toEqual({ + command: 'invalid', + reason: '--web-search needs --backend modelApi: paid features bill a Model API key.', + }) + expect(parseCommandLine(['--backend', 'museCode', '--image-generation'])).toEqual({ + command: 'invalid', + reason: '--image-generation needs --backend modelApi: paid features bill a Model API key.', + }) + }) + + it('reads the sign-in commands after the configured flags, as terminal sign-ins append them', () => { + expect(parseCommandLine(['--backend', 'modelApi', 'auth', 'set'])).toEqual({ + command: 'authSet', + }) + expect(parseCommandLine(['auth', 'status'])).toEqual({ command: 'authStatus' }) + expect(parseCommandLine(['auth', 'clear'])).toEqual({ command: 'authClear' }) + expect(parseCommandLine(['--muse-binary', '/m', 'login'])).toEqual({ + command: 'login', + options: { ...DEFAULTS, museBinary: '/m' }, + }) + expect(parseCommandLine(['-h'])).toEqual({ command: 'help' }) + expect(parseCommandLine(['--version'])).toEqual({ command: 'version' }) + }) + + it('refuses what it does not know, naming it', () => { + for (const argv of [ + ['--backend', 'auto'], + ['--shell-sandbox', 'maybe'], + ['auth', 'rotate'], + ['auth', 'set', 'extra'], + ['login', 'now'], + ['serve'], + ['--colour'], + ]) { + expect(parseCommandLine(argv).command).toBe('invalid') + } + expect(parseCommandLine(['--backend', 'auto'])).toEqual({ + command: 'invalid', + reason: 'Unknown argument: --backend auto', + }) + }) +}) + +describe('displayLanguage', () => { + it('reads the POSIX locale variables in their order, else the runtime’s locale', () => { + expect(displayLanguage({ LANG: 'de_DE.UTF-8' }, 'en-US')).toBe('de-de') + expect(displayLanguage({ LC_ALL: 'pt_BR@euro', LANG: 'de_DE' }, 'en-US')).toBe('pt-br') + expect(displayLanguage({ LC_MESSAGES: 'ja_JP' }, 'en-US')).toBe('ja-jp') + expect(displayLanguage({ LANG: 'C.UTF-8' }, 'fr-FR')).toBe('fr-fr') + expect(displayLanguage({ LANG: '' }, 'zh-TW')).toBe('zh-tw') + }) +}) + +describe('the data folder', () => { + it('lives where each platform keeps application data', () => { + expect( + agentDataFolder({ + platform: 'win32', + env: { LOCALAPPDATA: String.raw`C:\L` }, + homeDir: String.raw`C:\u`, + }), + ).toBe(String.raw`C:\L\Muse Spark Code`) + expect(agentDataFolder({ platform: 'win32', env: {}, homeDir: String.raw`C:\u` })).toBe( + String.raw`C:\u\AppData\Local\Muse Spark Code`, + ) + expect(agentDataFolder({ platform: 'darwin', env: {}, homeDir: '/Users/a' })).toBe( + '/Users/a/Library/Application Support/Muse Spark Code', + ) + expect(agentDataFolder({ platform: 'linux', env: {}, homeDir: '/home/a' })).toBe( + '/home/a/.local/share/muse-spark-code', + ) + expect( + agentDataFolder({ platform: 'linux', env: { XDG_DATA_HOME: '/d' }, homeDir: '/home/a' }), + ).toBe('/d/muse-spark-code') + }) + + it('keeps each workspace’s sessions under a hash of its path, never the path', () => { + const input = { platform: 'linux' as const, env: {}, homeDir: '/home/a' } + const one = workspaceSessionsFolder(input, '/work/one') + expect(one).toMatch( + /^\/home\/a\/\.local\/share\/muse-spark-code\/acp\/[0-9a-f]{16}\/modelapi-sessions$/, + ) + expect(one).not.toContain('work') + expect(workspaceSessionsFolder(input, '/work/two')).not.toBe(one) + }) +}) + +describe('the OS credential store (D61)', () => { + it('keeps the key under the agent’s service and the extension’s key name', async () => { + const keyring = fakeKeyring() + const store = keyringSecretStore(keyring.open) + await store.store(SECRET_KEYS.modelApiKey, KEY) + expect(await store.get(SECRET_KEYS.modelApiKey)).toBe(KEY) + await store.delete(SECRET_KEYS.modelApiKey) + expect(await store.get(SECRET_KEYS.modelApiKey)).toBeUndefined() + expect(new Set(keyring.opened)).toEqual( + new Set(['Muse Spark Code (Unofficial)/museSpark.modelApiKey']), + ) + }) + + it('reports no key, not a stored one, when the entry is missing', async () => { + const keyring = fakeKeyring() + const run = deps('', { ...keyringSecretStore(keyring.open), values: keyring.values }) + expect(await authStatus(run.deps)).toBe(1) + expect(run.printed).toEqual([UI_TEXT.acpKeyAbsent]) + }) + + it('names the store the way each platform does', () => { + expect(credentialStoreName('win32')).toBe(UI_TEXT.acpStoreNames.windows) + expect(credentialStoreName('darwin')).toBe(UI_TEXT.acpStoreNames.macos) + expect(credentialStoreName('linux')).toBe(UI_TEXT.acpStoreNames.linux) + }) +}) + +describe('the key’s commands', () => { + const broken = { + get: () => Promise.reject(new Error('no keyring')), + store: () => Promise.reject(new Error('no keyring')), + delete: () => Promise.reject(new Error('no keyring')), + } + + it('stores a valid key and says where, never what', async () => { + const run = deps(` ${KEY} `) + expect(await authSet(run.deps)).toBe(0) + expect(run.secrets.values.get(SECRET_KEYS.modelApiKey)).toBe(KEY) + expect(run.printed).toEqual(['The key is stored in the test store.']) + expect([...run.printed, ...run.errors].join('\n')).not.toContain('secret-value') + }) + + it('stores nothing for an empty line or a value that is not a key', async () => { + const empty = deps('') + expect(await authSet(empty.deps)).toBe(1) + expect(empty.errors).toEqual([UI_TEXT.acpKeyNotStored]) + const wrong = deps('sk-not-a-meta-key') + expect(await authSet(wrong.deps)).toBe(1) + expect(wrong.errors).toEqual([UI_TEXT.apiKeyInvalid]) + expect(wrong.secrets.values.size).toBe(0) + }) + + it('says whether a key is stored, and removes it', async () => { + const run = deps(KEY) + expect(await authStatus(run.deps)).toBe(1) + await authSet(run.deps) + expect(await authStatus(run.deps)).toBe(0) + expect(await authClear(run.deps)).toBe(0) + expect(run.printed).toEqual([ + UI_TEXT.acpKeyAbsent, + 'The key is stored in the test store.', + 'A Meta Model API key is stored in the test store.', + UI_TEXT.acpKeyCleared, + ]) + }) + + it('reports a store it cannot use, with the system’s reason', async () => { + for (const command of [authSet, authStatus, authClear]) { + const run = { ...deps(KEY), deps: { ...deps(KEY).deps, secrets: broken } } + const errors: string[] = [] + expect( + await command({ + ...run.deps, + printError: (text) => { + errors.push(text) + }, + }), + ).toBe(1) + expect(errors[0]).toContain('(no keyring)') + } + }) +}) + +describe('login', () => { + const launch: LaunchResolution = { + ok: true, + launch: { + command: '/usr/bin/node', + args: ['/opt/muse/cli.js', 'serve', '--stdio'], + serveArgs: ['serve', '--stdio'], + installDir: '/opt/muse', + cliPath: '/opt/muse/muse', + }, + } + + it('runs `muse login` the way the agent runs `muse serve`, and returns its exit code', async () => { + const child = fakeChild() + const spawnInTerminal = vi.fn(() => child) + const done = login({ + resolveLaunch: () => launch, + environment: () => ({ HOME: '/home/a' }), + spawnInTerminal, + printError: vi.fn(), + }) + child.emit('exit', 0) + expect(await done).toBe(0) + expect(spawnInTerminal).toHaveBeenCalledWith('/usr/bin/node', ['/opt/muse/cli.js', 'login'], { + HOME: '/home/a', + }) + }) + + it('reports a CLI it cannot find or start', async () => { + const errors: string[] = [] + const missing = await login({ + resolveLaunch: () => ({ ok: false, searched: ['/a', '/b'], reason: 'not installed.' }), + environment: () => ({}), + spawnInTerminal: vi.fn(), + printError: (text) => { + errors.push(text) + }, + }) + expect(missing).toBe(1) + expect(errors).toEqual(['not installed. Searched: /a, /b']) + const child = fakeChild() + const failed = login({ + resolveLaunch: () => launch, + environment: () => ({}), + spawnInTerminal: () => child, + printError: (text) => { + errors.push(text) + }, + }) + child.emit('error', new Error('EACCES')) + expect(await failed).toBe(1) + const killed = fakeChild() + const signalled = login({ + resolveLaunch: () => launch, + environment: () => ({}), + spawnInTerminal: () => killed, + printError: vi.fn(), + }) + killed.emit('exit', null) + expect(await signalled).toBe(1) + }) +}) + +describe('walkFiles', () => { + it('lists the tree breadth first, skipping .git, node_modules and links, up to the limit', async () => { + const root = folder() + mkdirSync(path.join(root, 'src', 'deep'), { recursive: true }) + mkdirSync(path.join(root, '.git')) + mkdirSync(path.join(root, 'node_modules', 'x'), { recursive: true }) + writeFileSync(path.join(root, 'a.ts'), '') + writeFileSync(path.join(root, 'src', 'b.ts'), '') + writeFileSync(path.join(root, 'src', 'deep', 'c.ts'), '') + writeFileSync(path.join(root, '.git', 'HEAD'), '') + writeFileSync(path.join(root, 'node_modules', 'x', 'i.js'), '') + symlinkSync(path.join(root, 'src'), path.join(root, 'linked'), 'junction') + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + expect(await walkFiles(root, 10, log)).toEqual(['a.ts', 'src/b.ts', 'src/deep/c.ts']) + expect(await walkFiles(root, 2, log)).toEqual(['a.ts', 'src/b.ts']) + expect(await walkFiles(path.join(root, 'missing'), 10, log)).toEqual([]) + expect(log.warn).toHaveBeenCalledTimes(1) + }) +}) + +describe('readSecretLine', () => { + it('takes the first line of a pipe without writing the prompt', async () => { + const input = new PassThrough() + const output = new PassThrough() + const written: string[] = [] + output.on('data', (chunk: Buffer) => { + written.push(chunk.toString()) + }) + const line = readSecretLine('Key: ', input, output) + input.end(`${KEY}\nignored\n`) + expect(await line).toBe(KEY) + expect(written).toEqual([]) + }) + + it('reads a terminal with echo off, honouring Backspace, and refuses Ctrl+C', async () => { + const terminal = Object.assign(new PassThrough(), { isTTY: true, setRawMode: vi.fn() }) + const output = new PassThrough() + const written: string[] = [] + output.on('data', (chunk: Buffer) => { + written.push(chunk.toString()) + }) + const line = readSecretLine('Key: ', terminal, output) + terminal.write('abx\u{7F}c\r') + expect(await line).toBe('abc') + expect(terminal.setRawMode.mock.calls).toEqual([[true], [false]]) + expect(written.join('')).toBe('Key: \n') + const cancelled = readSecretLine('Key: ', terminal, output) + terminal.write('a\u{3}') + await expect(cancelled).rejects.toThrow('Cancelled') + }) +}) + +describe('stderrLogger', () => { + it('writes from its level up, redacted', () => { + const lines: string[] = [] + const quiet = stderrLogger((line) => { + lines.push(line) + }, 'warn') + quiet.trace('t') + quiet.info('i') + quiet.warn(`key ${KEY}`) + quiet.error('e') + const verbose = stderrLogger((line) => { + lines.push(line) + }, 'trace') + verbose.trace('t') + verbose.info('i') + expect(lines[0]).toMatch(/^\[warn\] key /) + expect(lines[0]).not.toContain('secret-value') + expect(lines.slice(1)).toEqual(['[error] e', '[trace] t', '[info] i']) + }) +}) + +describe('createRuntimeBackend', () => { + const log = { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + // The agent's dist/ folder, with the Model API backend's own bundle (M57). + const dist = { folder: '' } + beforeAll(() => { + dist.folder = folder() + buildModelApiBundle(dist.folder) + }) + function backend( + options: Partial, + secrets = memorySecrets(), + env: NodeJS.ProcessEnv = {}, + distDir = dist.folder, + ) { + return createRuntimeBackend({ + options: { ...DEFAULTS, ...options }, + version: '0.0.0-test', + distDir, + platform: process.platform, + env, + homeDir: folder(), + secrets, + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [], + fetch: fakeModelApi().fetch, + sleep: () => Promise.resolve(), + log, + }) + } + + it('asks for a key the Model API backend does not have, and reports a store it cannot read', async () => { + const secrets = memorySecrets() + const runtime = backend({ backend: 'modelApi' }, secrets) + expect(await runtime.backend.readiness(false)).toEqual({ + state: 'signedOut', + message: UI_TEXT.acpNoStoredKey, + }) + secrets.values.set(SECRET_KEYS.modelApiKey, KEY) + expect(await runtime.backend.readiness(false)).toEqual({ state: 'ready' }) + const broken = backend( + { backend: 'modelApi' }, + Object.assign(memorySecrets(), { get: () => Promise.reject(new Error('locked')) }), + ) + const brokenReadiness = await broken.backend.readiness(false) + expect(brokenReadiness.state).toBe('unavailable') + }) + + it('says where it looked when the Muse Code CLI is missing, and builds a Model API host per folder', async () => { + // The CLI search reads this process's PATH, LOCALAPPDATA and home, so a + // machine with Muse Code installed must not be the one searched. + const home = folder() + vi.stubEnv('PATH', '') + vi.stubEnv('LOCALAPPDATA', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('HOME', home) + try { + const missing = backend( + { museBinary: path.join(folder(), 'no-such-muse') }, + memorySecrets(), + { + PATH: '', + }, + ) + const readiness = await missing.backend.readiness(false) + expect(readiness.state).toBe('unavailable') + } finally { + vi.unstubAllEnvs() + } + const secrets = memorySecrets() + secrets.values.set(SECRET_KEYS.modelApiKey, KEY) + const runtime = backend({ backend: 'modelApi' }, secrets) + const root = folder() + const host = await runtime.backend.hostFor(root) + expect(host.info.kind).toBe('modelApi') + expect(await runtime.backend.hostFor(root)).toBe(host) + await runtime.close() + }) + + it('loads the Model API backend from dist/modelApi.js beside the agent, and says so when it is missing (M57)', async () => { + const secrets = memorySecrets() + secrets.values.set(SECRET_KEYS.modelApiKey, KEY) + const empty = folder() + const runtime = backend({ backend: 'modelApi' }, secrets, {}, empty) + await expect(runtime.backend.hostFor(folder())).rejects.toThrow( + UI_TEXT.acpModelApiBundleUnavailable, + ) + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining(`The Model API bundle ${path.join(empty, 'modelApi.js')}`), + ) + }) + + it('lets "always" lapse at start only for the Model API agent, which has the flags (M58)', async () => { + const home = folder() + const env = { XDG_DATA_HOME: home, LOCALAPPDATA: home } + const file = paidGrantsFile({ platform: process.platform, env, homeDir: home }) + const workspace = path.resolve('work') + const grants = paidGrantFile({ file, log, sleep: () => Promise.resolve() }) + await grants.add(workspace, ['webSearch']) + const runtimeOn = (backend: ServeOptions['backend']) => + createRuntimeBackend({ + options: { ...DEFAULTS, backend }, + version: '0.0.0-test', + distDir: dist.folder, + platform: process.platform, + env, + homeDir: home, + secrets: memorySecrets(), + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [], + fetch: fakeModelApi().fetch, + sleep: () => Promise.resolve(), + log, + }) + // A Muse Code agent beside it leaves the Model API agent's grants alone. + await runtimeOn('museCode').forgetUnflaggedGrants() + expect(grants.read(workspace)).toEqual(new Set(['webSearch'])) + await runtimeOn('modelApi').forgetUnflaggedGrants() + expect(grants.read(workspace)).toEqual(new Set()) + }) + + it('keeps paid-use grants in the agent’s data folder (M58)', () => { + const home = folder() + const input = { platform: 'linux' as const, env: { XDG_DATA_HOME: home }, homeDir: home } + expect(paidGrantsFile(input)).toBe( + path.posix.join(home, 'muse-spark-code', 'acp', 'paid-uses.json'), + ) + expect( + paidGrantsFile({ platform: 'win32', env: { LOCALAPPDATA: String.raw`C:\L` }, homeDir: 'C:' }), + ).toBe(String.raw`C:\L\Muse Spark Code\acp\paid-uses.json`) + }) +}) + +/** The agent's own environment, with three credential variables among the rest. */ +function env(): NodeJS.ProcessEnv { + return { + META_API_KEY: 'LLM|1|placeholder', + OPENAI_API_KEY: 'sk-placeholder', + AWS_SECRET_ACCESS_KEY: 'placeholder', + PATH: '/usr/bin', + HOME: '/home/person', + } +} + +describe('credential variables (AGENTS.md rule 8; Codex on a209130)', () => { + it('takes every credential variable out of the agent’s own environment, and leaves the rest', () => { + const own = env() + expect(takeCredentials(own).map(({ name }) => name)).toEqual([ + 'META_API_KEY', + 'OPENAI_API_KEY', + 'AWS_SECRET_ACCESS_KEY', + ]) + expect(own).toEqual({ PATH: '/usr/bin', HOME: '/home/person' }) + const original = env() + expect(withoutCredentials(original)).toEqual({ PATH: '/usr/bin', HOME: '/home/person' }) + expect(original['META_API_KEY']).toBe('LLM|1|placeholder') + }) + + it('hands them back to Muse Code only, where META_API_KEY counts as its credential (D1)', () => { + vi.stubEnv('META_API_KEY', '') + try { + const runtime = createRuntimeBackend({ + options: DEFAULTS, + version: '0.0.0-test', + distDir: folder(), + platform: process.platform, + env: {}, + homeDir: folder(), + secrets: memorySecrets(), + runGit: () => Promise.reject(new Error('no git')), + museCodeCredentials: [{ name: 'META_API_KEY', value: 'LLM|1|placeholder' }], + fetch: fakeModelApi().fetch, + sleep: () => Promise.resolve(), + log: { trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }) + expect(runtime.museCode.childEnvironment()['META_API_KEY']).toBe('LLM|1|placeholder') + expect(runtime.museCode.hasEnvironmentKey()).toBe(true) + } finally { + vi.unstubAllEnvs() + } + }) +}) + +describe('webReadable', () => { + it('passes the chunks on and ends with its source', async () => { + const source = new PassThrough() + const reader = webReadable(source).getReader() + source.write(Buffer.from('ab')) + const first = await reader.read() + expect(Buffer.from(first.value ?? []).toString()).toBe('ab') + source.end() + const last = await reader.read() + expect(last.done).toBe(true) + }) + + it('fails with its source', async () => { + const source = new PassThrough() + const reader = webReadable(source).getReader() + source.destroy(new Error('pipe broke')) + await expect(reader.read()).rejects.toThrow('pipe broke') + }) + + it('lets go of its source once cancelled, so a late end or error is ignored', async () => { + const source = new PassThrough() + const stream = webReadable(source) + await stream.cancel() + expect(source.isPaused()).toBe(true) + source.end() + source.emit('error', new Error('late')) + expect(source.listenerCount('data')).toBe(0) + }) +}) diff --git a/test/unit/acpTranslate.test.ts b/test/unit/acpTranslate.test.ts new file mode 100644 index 00000000..f87f65df --- /dev/null +++ b/test/unit/acpTranslate.test.ts @@ -0,0 +1,647 @@ +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { formAnswers, questionForm, questionsText } from '../../src/acp/questions' +import { + approvalToolCall, + decidedChoice, + mcpServersFrom, + permissionOptions, + permissionResponse, + promptParts, + toolKind, + toolName, + UpdateTranslator, +} from '../../src/acp/translate' +import type { ApprovalChoice, ItemSnapshot, Question } from '../../src/shared/agentEvents' +import { + ACP_TOOL_OUTPUT_MAX_CHARS, + SELECTION_TEXT_MAX_CHARS, + UI_TEXT, +} from '../../src/shared/constants' + +// M63 (PLAN.md D62): what an ACP client sees of the panel's events, prompts, +// approvals and questions. + +const CWD = path.resolve('/work/app') +// A 1×1 PNG. +const PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + +function tool(overrides: Partial): ItemSnapshot { + return { + itemId: 't1', + kind: 'toolCall', + status: 'inProgress', + tool: 'powershell', + args: JSON.stringify({ command: 'npm test', description: 'Run the tests' }), + ...overrides, + } +} + +describe('UpdateTranslator', () => { + it('announces a tool call once, streams nothing of its output, and sends it whole at the end', () => { + const translator = new UpdateTranslator(CWD, false) + expect(translator.updates({ type: 'itemStarted', item: tool({}) })).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'PowerShell: Run the tests', + kind: 'execute', + status: 'in_progress', + locations: [], + rawInput: { command: 'npm test', description: 'Run the tests' }, + }, + ]) + expect( + translator.updates({ type: 'textDelta', itemId: 't1', field: 'output', delta: 'ok ' }), + ).toEqual([]) + translator.updates({ type: 'textDelta', itemId: 't1', field: 'output', delta: '12 tests' }) + expect(translator.updates({ type: 'itemUpdated', item: tool({}) })).toEqual([ + { sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'in_progress' }, + ]) + expect( + translator.updates({ type: 'itemCompleted', item: tool({ status: 'completed' }) }), + ).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'ok 12 tests' } }], + }, + ]) + }) + + it('shows an edit as a diff with its absolute path, a new file with no old text', () => { + const translator = new UpdateTranslator(CWD, false) + const edit = tool({ + tool: 'edit_file', + status: 'completed', + args: JSON.stringify({ path: 'src/a.ts', old_str: 'x', new_str: 'y' }), + visibleOutput: 'Edited src/a.ts', + }) + const [announced, finished] = translator.updates({ type: 'itemCompleted', item: edit }) + expect(announced).toMatchObject({ + sessionUpdate: 'tool_call', + kind: 'edit', + title: 'Edit: src/a.ts', + status: 'completed', + locations: [{ path: path.join(CWD, 'src/a.ts') }], + }) + expect(finished).toMatchObject({ + content: [ + { type: 'diff', path: path.join(CWD, 'src/a.ts'), oldText: 'x', newText: 'y' }, + { type: 'content', content: { type: 'text', text: 'Edited src/a.ts' } }, + ], + }) + const write = tool({ + itemId: 't2', + tool: 'write_file', + status: 'completed', + args: JSON.stringify({ path: 'new.ts', content: 'hello' }), + }) + expect(translator.updates({ type: 'itemCompleted', item: write })[1]).toMatchObject({ + content: [{ type: 'diff', path: path.join(CWD, 'new.ts'), oldText: null, newText: 'hello' }], + }) + const patch = tool({ + itemId: 't3', + tool: 'apply_patch', + status: 'completed', + args: JSON.stringify({ path: 'a.ts' }), + }) + expect(translator.updates({ type: 'itemCompleted', item: patch })[1]).toMatchObject({ + content: [], + }) + }) + + it('fails a declined or failed call with its reason, clipped output and all', () => { + const translator = new UpdateTranslator(CWD, false) + const long = 'x'.repeat(ACP_TOOL_OUTPUT_MAX_CHARS + 10) + const [, failed] = translator.updates({ + type: 'itemCompleted', + item: tool({ status: 'failed', visibleOutput: long, failureReason: 'exit code 1' }), + }) + expect(failed).toMatchObject({ status: 'failed' }) + const content = failed?.sessionUpdate === 'tool_call_update' ? failed.content : undefined + expect(content?.[1]).toEqual({ + type: 'content', + content: { type: 'text', text: 'exit code 1' }, + }) + const first = content?.[0] + expect( + first?.type === 'content' && first.content.type === 'text' ? first.content.text.length : 0, + ).toBe(ACP_TOOL_OUTPUT_MAX_CHARS) + // Muse Code's `declined` and the Model API backend's `rejected` are the user's no. + for (const status of ['declined', 'rejected']) { + const [, denied] = translator.updates({ + type: 'itemCompleted', + item: tool({ itemId: status, status }), + }) + expect(denied).toMatchObject({ status: 'failed' }) + } + }) + + it('shows a user shell command, a subagent and arguments that are not JSON', () => { + const translator = new UpdateTranslator(CWD, false) + expect( + translator.updates({ + type: 'itemStarted', + item: { itemId: 's1', kind: 'userShell', status: 'inProgress', args: 'ls -la' }, + })[0], + ).toMatchObject({ title: 'Shell', kind: 'execute', rawInput: 'ls -la' }) + const subagent: ItemSnapshot = { + itemId: 'g1', + kind: 'subagent', + status: 'completed', + objective: 'Map the workspace', + result: { summary: 'Mapped' }, + } + expect(translator.updates({ type: 'itemCompleted', item: subagent })).toMatchObject([ + { title: 'Spawn agent: Map the workspace', kind: 'other' }, + { rawOutput: { summary: 'Mapped' } }, + ]) + }) + + it('streams reasoning as thoughts, a new summary part after a blank line', () => { + const translator = new UpdateTranslator(CWD, false) + translator.updates({ + type: 'itemStarted', + item: { itemId: 'r1', kind: 'reasoning', status: 'inProgress' }, + }) + const deltas = [ + translator.updates({ type: 'textDelta', itemId: 'r1', field: 'summary.0', delta: 'First' }), + translator.updates({ type: 'textDelta', itemId: 'r1', field: 'summary.0', delta: ' part' }), + translator.updates({ type: 'textDelta', itemId: 'r1', field: 'summary.1', delta: 'Second' }), + ].flat() + expect(deltas.map((update) => update.sessionUpdate)).toEqual([ + 'agent_thought_chunk', + 'agent_thought_chunk', + 'agent_thought_chunk', + ]) + expect(deltas[2]).toMatchObject({ content: { text: '\n\nSecond' } }) + expect( + translator.updates({ + type: 'itemCompleted', + item: { + itemId: 'r1', + kind: 'reasoning', + status: 'completed', + summary: ['First part', 'Second'], + }, + }), + ).toEqual([]) + translator.updates({ + type: 'itemStarted', + item: { itemId: 'r2', kind: 'reasoning', status: 'inProgress' }, + }) + expect( + translator.updates({ type: 'textDelta', itemId: 'r2', field: 'text', delta: 'raw' }), + ).toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'raw' } }]) + }) + + it('ignores deltas of items it does not stream and fields it does not know', () => { + const translator = new UpdateTranslator(CWD, false) + expect( + translator.updates({ type: 'textDelta', itemId: 'nobody', field: 'text', delta: 'x' }), + ).toEqual([]) + translator.updates({ + type: 'itemStarted', + item: { itemId: 'm1', kind: 'agentMessage', status: 'inProgress' }, + }) + expect( + translator.updates({ type: 'textDelta', itemId: 'm1', field: 'summary.0', delta: 'x' }), + ).toEqual([]) + expect( + translator.updates({ type: 'textDelta', itemId: 'm1', field: 'title', delta: 'x' }), + ).toEqual([]) + expect( + translator.updates({ + type: 'itemStarted', + item: { itemId: 'k', kind: 'reminderChild', status: 'x' }, + }), + ).toEqual([]) + expect(translator.updates({ type: 'turnStarted', turnId: 'turn-1' })).toEqual([]) + }) + + it('replays the user’s messages only from a loaded history', () => { + const user: ItemSnapshot = { + itemId: 'u1', + kind: 'userMessage', + status: 'completed', + text: 'Hi', + } + expect(new UpdateTranslator(CWD, false).itemUpdates(user, true)).toEqual([]) + expect(new UpdateTranslator(CWD, true).itemUpdates(user, true)).toEqual([ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Hi' } }, + ]) + }) + + it('sends the session’s name, its context use, the backend’s notices and the todo list', () => { + const translator = new UpdateTranslator(CWD, false) + expect(translator.updates({ type: 'sessionNamed', name: 'Refactor' })).toEqual([ + { sessionUpdate: 'session_info_update', title: 'Refactor' }, + ]) + expect( + translator.updates({ + type: 'contextUsage', + usedTokens: 10, + windowTokens: 100, + pressure: 'low', + }), + ).toEqual([{ sessionUpdate: 'usage_update', used: 10, size: 100 }]) + expect(translator.updates({ type: 'contextUsage', usedTokens: 10, pressure: 'low' })).toEqual( + [], + ) + expect(translator.updates({ type: 'backendNotice', level: 'warning', text: 'Slow' })).toEqual([ + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Slow\n\n' } }, + ]) + expect( + translator.updates({ + type: 'todoChanged', + items: [ + { text: 'A', status: 'pending' }, + { text: 'B', status: 'inProgress', activeForm: 'Doing B' }, + { text: 'C', status: 'done' }, + ], + }), + ).toEqual([ + { + sessionUpdate: 'plan', + entries: [ + { content: 'A', priority: 'medium', status: 'pending' }, + { content: 'Doing B', priority: 'medium', status: 'in_progress' }, + { content: 'C', priority: 'medium', status: 'completed' }, + ], + }, + ]) + }) +}) + +describe('tool names and kinds', () => { + it('names a tool from the table, an MCP tool by its server, anything else as it came', () => { + expect(toolName('read_file')).toBe('Read') + expect(toolName('mcp__github__create_issue')).toBe('create_issue (github)') + expect(toolName('mystery')).toBe('mystery') + }) + + it('gives each family its kind', () => { + expect( + [ + 'bash', + 'code_exec', + 'edit_file', + 'generate_image', + 'read_file', + 'read_memory', + 'search', + 'web_search', + 'web_fetch', + 'todo_write', + 'mystery', + ].map((name) => toolKind(name)), + ).toEqual([ + 'execute', + 'execute', + 'edit', + 'edit', + 'read', + 'read', + 'search', + 'search', + 'fetch', + 'think', + 'other', + ]) + }) +}) + +describe('approvals', () => { + const choices: ApprovalChoice[] = [ + { choiceId: 'a1', label: 'Allow once', decision: 'approved', scope: 'once' }, + { + choiceId: 'a2', + label: 'Always', + decision: 'approvedPolicyAmendment', + scope: 'localPersistent', + }, + { choiceId: 'd2', label: 'Never', decision: 'abort', scope: 'session' }, + { choiceId: 'd1', label: 'Reject', decision: 'abort', scope: 'once' }, + ] + + it('reads a permission answer only in its schema, and anything else as a cancel (rule 7)', () => { + const cancelled = { outcome: { outcome: 'cancelled' } } + expect(permissionResponse({ outcome: { outcome: 'selected', optionId: 'a1' } })).toEqual({ + outcome: { outcome: 'selected', optionId: 'a1' }, + }) + expect(permissionResponse(cancelled)).toEqual(cancelled) + for (const odd of [ + undefined, + null, + 'allow', + { outcome: 'yes' }, + { outcome: { outcome: 'selected' } }, + ]) { + expect(permissionResponse(odd)).toEqual(cancelled) + } + }) + + it('offers each choice under its own id, label and kind', () => { + expect(permissionOptions(choices)).toEqual([ + { optionId: 'a1', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'a2', name: 'Always', kind: 'allow_always' }, + { optionId: 'd2', name: 'Never', kind: 'reject_always' }, + { optionId: 'd1', name: 'Reject', kind: 'reject_once' }, + ]) + }) + + it('decides with the picked choice, else the backend’s deny-once, else any deny, else none', () => { + expect( + decidedChoice({ outcome: { outcome: 'selected', optionId: 'a2' } }, choices)?.choiceId, + ).toBe('a2') + expect(decidedChoice({ outcome: { outcome: 'cancelled' } }, choices)?.choiceId).toBe('d1') + expect(decidedChoice(undefined, choices.slice(0, 3))?.choiceId).toBe('d2') + expect(decidedChoice(undefined, choices.slice(0, 2))).toBeUndefined() + }) + + it('titles the request by what it is about: the stages, the file, the host', () => { + const base = { + type: 'approvalRequested' as const, + approvalId: 'x', + itemId: 'i', + toolName: 'bash', + rawArgs: '{}', + requirementId: { approvalId: 'x', sourceIndex: 0 }, + availableChoices: choices, + isJudgeEscalated: false, + isProtectedWrite: false, + } + const stages = [ + { + requirementId: { approvalId: 'x', sourceIndex: 0 }, + position: 1, + totalStages: 2, + argv: ['git', 'add', '.'], + }, + { + requirementId: { approvalId: 'x', sourceIndex: 1 }, + position: 2, + totalStages: 2, + argv: ['git', 'commit'], + }, + ] + expect(approvalToolCall({ ...base, subject: { kind: 'command', stages } }, CWD).title).toBe( + 'Bash: git add . ; git commit', + ) + expect( + approvalToolCall( + { ...base, toolName: 'write_file', subject: { kind: 'fileWrite', path: '.env' } }, + CWD, + ).title, + ).toBe('Write: .env') + expect( + approvalToolCall({ ...base, subject: { kind: 'network', host: 'example.com' } }, CWD).title, + ).toBe('Bash: example.com') + expect( + approvalToolCall( + { ...base, rawArgs: JSON.stringify({ path: 'a.ts' }), subject: { kind: 'other' } }, + CWD, + ), + ).toMatchObject({ title: 'Bash: a.ts', locations: [{ path: path.join(CWD, 'a.ts') }] }) + }) +}) + +describe('promptParts', () => { + it('takes text, images, links inside the folder as mentions and attached text as context', () => { + const inside = pathToFileURL(path.join(CWD, 'src', 'app.ts')).href + const outside = pathToFileURL(path.resolve('/elsewhere/x.ts')).href + const result = promptParts( + [ + { type: 'text', text: 'Look at' }, + { type: 'resource_link', uri: inside, name: 'app.ts' }, + { type: 'resource_link', uri: outside, name: 'x.ts' }, + { type: 'resource_link', uri: 'https://example.com/doc', name: 'doc' }, + { + type: 'resource', + resource: { uri: inside, text: 'y'.repeat(SELECTION_TEXT_MAX_CHARS + 5) }, + }, + { type: 'image', data: PNG, mimeType: 'image/png' }, + ], + CWD, + ) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.displayText).toBe('Look at') + expect(result.parts.slice(0, 4)).toEqual([ + { type: 'text', text: 'Look at' }, + { type: 'text', text: '@src/app.ts' }, + { type: 'text', text: outside }, + { type: 'text', text: 'https://example.com/doc' }, + ]) + const attached = result.parts[4] + expect(attached?.type === 'text' ? attached.text.length : 0).toBeLessThan( + SELECTION_TEXT_MAX_CHARS + 100, + ) + expect(result.parts[5]).toEqual({ + type: 'image', + base64Data: PNG, + mediaType: 'image/png', + width: 1, + height: 1, + }) + }) + + it('refuses a prompt with an image too large, a file that is not one, audio, or a link it cannot map', () => { + const huge = Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64') + expect(promptParts([{ type: 'image', data: huge, mimeType: 'image/png' }], CWD)).toEqual({ + ok: false, + reason: UI_TEXT.attachmentTooLarge, + }) + expect( + promptParts([{ type: 'resource', resource: { uri: 'file:///a.bin', blob: 'AAAA' } }], CWD), + ).toEqual({ ok: false, reason: UI_TEXT.attachmentUnsupported }) + expect(promptParts([{ type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }], CWD)).toEqual({ + ok: false, + reason: UI_TEXT.attachmentUnsupported, + }) + const share = 'file://server/share/x.ts' + const mapped = promptParts([{ type: 'resource_link', uri: share, name: 'x' }], CWD) + expect(mapped.ok && mapped.parts[0]?.type === 'text' ? mapped.parts[0].text : '').toMatch( + /x\.ts/, + ) + }) +}) + +describe('mcpServersFrom (M63c)', () => { + it('keeps stdio and HTTP servers by name, and leaves out SSE, the ACP transport and a repeated name', () => { + const forwarded = mcpServersFrom([ + { name: 'notes', command: 'notes-mcp', args: ['--stdio'], env: [{ name: 'A', value: '1' }] }, + { type: 'http', name: 'jupyter', url: 'http://h/mcp', headers: [{ name: 'X', value: 'y' }] }, + { type: 'sse', name: 'legacy', url: 'http://h/sse', headers: [] }, + { type: 'acp', name: 'nested', serverId: 'agent-mcp' }, + { name: 'notes', command: 'other', args: [], env: [] }, + ]) + expect(forwarded).toEqual({ + servers: { + notes: { command: 'notes-mcp', args: ['--stdio'], env: { A: '1' } }, + jupyter: { url: 'http://h/mcp', headers: { X: 'y' } }, + }, + skipped: ['legacy', 'nested', 'notes'], + }) + }) + + it('takes a stdio server sent with an explicit type, as some clients do', () => { + const server = JSON.parse( + '{"type":"stdio","name":"s","command":"c","args":[],"env":[]}', + ) as Parameters[0][number] + expect(mcpServersFrom([server]).servers).toEqual({ s: { command: 'c', args: [], env: {} } }) + }) +}) + +/** A form the user accepted with this content. */ +function accepted(content: unknown) { + return { action: 'accept', content } +} + +describe('questions', () => { + const single: Question = { + id: 'q1', + header: 'Colour', + question: 'Which one?', + selection: { mode: 'single' }, + options: [{ label: 'Blue', description: 'the sea' }, { label: 'Red' }], + } + const multiple: Question = { + id: 'q2', + header: 'Parts', + question: 'Which parts?', + selection: { mode: 'multiple', minSelections: 1, maxSelections: 2 }, + options: [{ label: 'A' }, { label: 'B' }], + } + const open: Question = { + id: 'q3', + header: 'Name', + question: 'What name?', + selection: { mode: 'single' }, + options: [], + } + + it('asks each question as a required field of the right kind', () => { + expect(questionForm([single, multiple, open])).toEqual({ + type: 'object', + properties: { + q1: { + type: 'string', + title: 'Colour', + description: 'Which one?', + oneOf: [ + { const: 'Blue', title: 'Blue', description: 'the sea' }, + { const: 'Red', title: 'Red' }, + ], + }, + q2: { + type: 'array', + title: 'Parts', + description: 'Which parts?', + items: { + anyOf: [ + { const: 'A', title: 'A' }, + { const: 'B', title: 'B' }, + ], + }, + minItems: 1, + maxItems: 2, + }, + q3: { type: 'string', title: 'Name', description: 'What name?' }, + }, + required: ['q1', 'q2', 'q3'], + }) + }) + + it('reads the answers: an option, several, free text', () => { + expect( + formAnswers([single, multiple, open], { + action: 'accept', + content: { q1: 'Blue', q2: ['A', 'B'], q3: 'Muse' }, + }), + ).toEqual([ + { questionId: 'q1', selectedLabel: 'Blue' }, + { questionId: 'q2', selectedLabels: ['A', 'B'] }, + { questionId: 'q3', freeText: 'Muse' }, + ]) + expect(formAnswers([single], { action: 'decline' })).toBeUndefined() + expect(formAnswers([single], { action: 'cancel' })).toBeUndefined() + }) + + it.each([ + ['a response that is not one', 'accept'], + ['an unknown action', { action: 'maybe' }], + ['a missing answer', { action: 'accept' }], + ['a label that is not an option', { action: 'accept', content: { q1: 'Green' } }], + ['a number for a choice', { action: 'accept', content: { q1: 1 } }], + ['a list for a single choice', { action: 'accept', content: { q1: ['Blue'] } }], + ])('answers nothing on %s (rule 7)', (_name, raw) => { + expect(formAnswers([single], raw)).toBeUndefined() + }) + + it.each([ + ['fewer than it needs', []], + ['more than it allows', ['A', 'B', 'A']], + ['the same option twice', ['A', 'A']], + ['an option it does not have', ['A', 'C']], + ['text instead of a list', 'A'], + ])('answers nothing for a multiple choice with %s', (_name, picked) => { + expect(formAnswers([multiple], { action: 'accept', content: { q2: picked } })).toBeUndefined() + }) + + it('answers nothing for blank free text or an answer to a question it did not ask', () => { + expect(formAnswers([open], { action: 'accept', content: { q3: ' ' } })).toBeUndefined() + expect(formAnswers([open], { action: 'accept', content: { other: 'x' } })).toBeUndefined() + }) + + it('declines a form whose answers do not fit the questions (AGENTS.md rule 7)', () => { + for (const response of [ + // Not an answer the client can give, or a field left out. + undefined, + { action: 'accept' }, + accepted({ q2: ['A'] }), + accepted(['Blue', ['A']]), + ]) { + expect(formAnswers([single, multiple], response)).toBeUndefined() + } + for (const content of [ + { q1: 'Blue', q2: ['A', 2] }, + { q1: 'Blue', q2: ['A', 'C'] }, + { q1: 'Blue', q2: ['A', 'A'] }, + { q1: 'Blue', q2: [] }, + { q1: 'Blue', q2: 'A' }, + { q1: ['Blue'], q2: ['A'] }, + { q1: 3, q2: ['A'] }, + { q1: ' ', q2: ['A'] }, + ]) { + expect(formAnswers([single, multiple], accepted(content))).toBeUndefined() + } + const atMostOne: Question = { ...multiple, selection: { mode: 'multiple', maxSelections: 1 } } + expect(formAnswers([atMostOne], accepted({ q2: ['A', 'B'] }))).toBeUndefined() + // Without bounds, one up to every option, as the panel's card asks. + const unbounded: Question = { ...multiple, selection: { mode: 'multiple' } } + expect(formAnswers([unbounded], accepted({ q2: [] }))).toBeUndefined() + expect(formAnswers([unbounded], accepted({ q2: ['A', 'B'] }))).toEqual([ + { questionId: 'q2', selectedLabels: ['A', 'B'] }, + ]) + // Fields for no question are ignored. + expect(formAnswers([single], accepted({ q1: 'Red', other: 7 }))).toEqual([ + { questionId: 'q1', selectedLabel: 'Red' }, + ]) + // ACP's accept may carry null content: an answer, with nothing in it. + expect(formAnswers([], accepted(null))).toEqual([]) + expect(formAnswers([single], accepted(null))).toBeUndefined() + }) + + it('writes the questions as text for a client without forms', () => { + expect(questionsText([single, open])).toBe( + `${UI_TEXT.acpQuestionAsked}\nWhich one?\n- Blue\n- Red\nWhat name?`, + ) + }) +}) diff --git a/test/unit/conversationController.test.ts b/test/unit/conversationController.test.ts index 8d4793fb..2ebbbf48 100644 --- a/test/unit/conversationController.test.ts +++ b/test/unit/conversationController.test.ts @@ -19,8 +19,7 @@ import { type PickedFile, type SessionMemory, } from '../../src/host/conversation/conversationController' -import type { DictationListener } from '../../src/core/voice/dictation' -import type { DictationSetup } from '../../src/host/voice/dictationHost' +import type { DictationListener, DictationSetup } from '../../src/core/voice/dictation' import { CHOICE_STEERING_NOTE, MAX_DOCUMENT_BYTES, diff --git a/test/unit/credentialStore.test.ts b/test/unit/credentialStore.test.ts index 2c8439e5..59431256 100644 --- a/test/unit/credentialStore.test.ts +++ b/test/unit/credentialStore.test.ts @@ -81,5 +81,21 @@ describe('CredentialStore', () => { await expect(store.getApiKey()).resolves.toBeUndefined() expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('no keyring') + expect(warnings[0]).toMatch(/^VS Code's secret storage could not be read/) + // The ACP agent names its own store (PLAN.md D61). + const agentWarnings: string[] = [] + const agentStore = new CredentialStore( + { + get: () => Promise.reject(new Error('locked')), + store: () => Promise.resolve(), + delete: () => Promise.resolve(), + }, + (message) => { + agentWarnings.push(message) + }, + "the operating system's credential store", + ) + await agentStore.getApiKey() + expect(agentWarnings[0]).toMatch(/^the operating system's credential store could not be read/) }) }) diff --git a/test/unit/helpers/acpMsp.ts b/test/unit/helpers/acpMsp.ts new file mode 100644 index 00000000..ece9919f --- /dev/null +++ b/test/unit/helpers/acpMsp.ts @@ -0,0 +1,75 @@ +// ACP lifecycle regressions use the real MuseCodeHost and MuseSession over +// the SDK's in-memory transport, so retention and late wire answers are real. + +import { MuseCodeHost } from '../../../src/core/backends/musecode/MuseCodeHost' +import { FakeLogOutputChannel } from './fakes' +import { fakeMspHost, type FakeMspServer } from './fakeMsp' + +const COMMAND_TIMEOUT_MS = 2000 +const SESSION_ID = 'old-1' + +function ack(params: Record) { + return { commandId: params['commandId'] } +} + +/** M6's captured resume envelope, trimmed to the fields this regression needs. */ +export function acpResumeEnvelope() { + return { + session: { + sessionId: SESSION_ID, + status: 'idle', + activeTurnId: null, + createdAt: '2026-09-22T10:00:00Z', + updatedAt: '2026-09-22T11:00:00Z', + workspaceRoot: '/ws', + turnCount: 1, + }, + history: { mode: 'inline', items: [], snapshot: null }, + pendingRequests: [], + viewCursor: 'v:old:3', + } +} + +/** Answers an intentionally held MSP request through the actual wire. */ +export function answerMsp( + server: FakeMspServer, + method: string, + index: number, + result: Readonly>, +): void { + const request = server.requestsFor(method)[index] + if (request?.id === undefined) { + throw new Error(`no request ${method} at ${String(index)}`) + } + server.incoming.push( + `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { commandId: request.params?.['commandId'], ...result } })}\n`, + ) +} + +export function acpMspHost() { + const handle = fakeMspHost() + const host = new MuseCodeHost(handle.host, new FakeLogOutputChannel(), { + normalMs: COMMAND_TIMEOUT_MS, + longMs: COMMAND_TIMEOUT_MS, + }) + const { server } = handle + server.handle('model/list', () => ({ + models: [ + { + modelId: 'muse-spark-1.3', + displayLabel: 'Muse Spark 1.3', + contextLimit: null, + isDefault: true, + isActive: true, + }, + ], + })) + server.handle('session/resume', acpResumeEnvelope) + server.handle('view/page', () => ({ events: [], nextCursor: null })) + server.handle('session/setApprovalMode', ack) + server.handle('session/setReasoningEffort', ack) + server.handle('skill/list', () => ({ skills: [] })) + server.handle('turn/cancel', ack) + server.handle('task/stopAll', ack) + return { ...handle, host } +} diff --git a/test/unit/helpers/fakeAgent.ts b/test/unit/helpers/fakeAgent.ts new file mode 100644 index 00000000..febfefde --- /dev/null +++ b/test/unit/helpers/fakeAgent.ts @@ -0,0 +1,212 @@ +// A scripted AgentHost and AgentSession for the ACP agent's tests (M63): each +// call is a spy, and a test plays the backend's side by emitting events. + +import { vi } from 'vitest' +import type { + AgentHost, + AgentSession, + HostExit, + HostInfo, + LoadedSession, + ModelSummary, + SessionEventListener, + SessionPage, + SkillSummary, + TurnSubmission, +} from '../../../src/core/agent/agentBackend' +import type { AgentEvent, ItemSnapshot, TodoItem } from '../../../src/shared/agentEvents' + +function resolved(): Promise { + return Promise.resolve() +} + +function unsubscribe(): undefined { + return undefined +} + +function sameTurn(expectedTurnId: string): Promise { + return Promise.resolve({ turnId: expectedTurnId, disposition: 'steered' }) +} + +function noOutput(request: { readonly itemId: string }): Promise { + return Promise.reject(new Error(`no output ${request.itemId}`)) +} + +function sameName(name: string): Promise { + return Promise.resolve(name) +} + +function goalAdmitted(): ReturnType { + return Promise.resolve({ turnId: undefined }) +} + +export class FakeAgentSession implements AgentSession { + private readonly listeners = new Set() + private turns = 0 + public skills: readonly SkillSummary[] = [] + /** Approvals and questions still open, handed to each new listener. */ + public openPrompts: readonly AgentEvent[] = [] + public readonly sendTurn = vi.fn(() => { + this.turns += 1 + return Promise.resolve({ turnId: `turn-${String(this.turns)}`, disposition: 'started' }) + }) + public readonly steer = vi.fn(sameTurn) + public readonly cancel = vi.fn(resolved) + public readonly setModel = vi.fn(resolved) + public readonly setReasoningEffort = vi.fn(resolved) + public readonly setApprovalMode = vi.fn(resolved) + public readonly compact = vi.fn(() => + Promise.resolve({ status: this.sessionId, reason: undefined }), + ) + public readonly decideApproval = vi.fn(resolved) + public readonly answerQuestions = vi.fn(resolved) + public readonly cancelQuestions = vi.fn(resolved) + public readonly controlSubagent = vi.fn(resolved) + public readonly messageSubagent = vi.fn(resolved) + public readonly controlGoal = vi.fn(goalAdmitted) + public readonly clarifyQuestions = vi.fn(resolved) + public readonly moveToBackground = vi.fn(resolved) + public readonly stopTask = vi.fn(resolved) + public readonly stopAllTasks = vi.fn(resolved) + public readonly runUserShell = vi.fn(resolved) + public readonly readOutput = vi.fn(noOutput) + public readonly listSkills = vi.fn(() => Promise.resolve(this.skills)) + public readonly rename = vi.fn(sameName) + public readonly dispose = vi.fn() + + public constructor( + public readonly sessionId: string, + public readonly modelId: string, + ) {} + + public onEvent(listener: SessionEventListener): () => void { + this.listeners.add(listener) + // As Muse Code hands a later listener the prompts still open. + for (const event of this.openPrompts) { + listener(event) + } + return () => { + this.listeners.delete(listener) + } + } + + public emit(...events: AgentEvent[]): void { + for (const event of events) { + for (const listener of this.listeners) { + listener(event) + } + } + } +} + +export const FAKE_MODELS: readonly ModelSummary[] = [ + { + modelId: 'muse-spark-1.3', + displayLabel: 'Muse Spark 1.3', + contextLimit: 1_000_000, + isDefault: true, + isActive: true, + }, + { + modelId: 'muse-spark-1.3-contributor', + displayLabel: 'Muse Spark 1.3 (contributor)', + contextLimit: 1_000_000, + isDefault: false, + isActive: false, + }, +] + +export interface FakeHistory { + readonly items: readonly ItemSnapshot[] + readonly todos: readonly TodoItem[] +} + +export class FakeAgentHost implements AgentHost { + private readonly exitListeners = new Set<(exit: HostExit) => void>() + public info: HostInfo = { + kind: 'museCode', + serverName: 'fake', + serverVersion: '0.0.0', + grantedCapabilities: ['sessionMcp'], + canEditSessions: true, + } + public readonly sessions: FakeAgentSession[] = [] + public history: FakeHistory = { items: [], todos: [] } + public page: SessionPage = { sessions: [], nextCursor: undefined } + public readonly startSession = vi.fn((options) => + Promise.resolve( + this.newSession(`session-${String(this.sessions.length + 1)}`, options.modelId), + ), + ) + public readonly resumeSession = vi.fn((sessionId, modelId) => { + const loaded: LoadedSession = { + session: this.newSession(sessionId, modelId), + record: { + sessionId, + createdAt: '2026-09-26T00:00:00Z', + updatedAt: '2026-09-26T00:00:00Z', + status: 'idle', + turnCount: 1, + }, + history: { + mode: 'inline', + items: this.history.items, + name: undefined, + todos: this.history.todos, + }, + activeTurnId: undefined, + } + return Promise.resolve(loaded) + }) + public readonly listSessions = vi.fn(() => Promise.resolve(this.page)) + public readonly listModels = vi.fn(() => Promise.resolve(this.models)) + + public constructor(public models: readonly ModelSummary[] = FAKE_MODELS) {} + + private newSession(sessionId: string, modelId: string): FakeAgentSession { + const session = new FakeAgentSession(sessionId, modelId) + this.sessions.push(session) + return session + } + + public get sessionCount(): number { + return this.sessions.length + } + + public onExit(listener: (exit: HostExit) => void): () => void { + this.exitListeners.add(listener) + return () => { + this.exitListeners.delete(listener) + } + } + + public exit(description: string): void { + for (const listener of this.exitListeners) { + listener({ description, isExpected: false, isPersistent: false }) + } + } + + public readSession(): Promise { + return Promise.reject(new Error('not used')) + } + + public forkSession(): Promise { + return Promise.reject(new Error('not used')) + } + + public onSessionListEvent(): () => void { + return unsubscribe + } + + public readUsage(): Promise { + return Promise.resolve(undefined) + } + + public onUsageChanged(): () => void { + return unsubscribe + } + + public close(): Promise { + return Promise.resolve() + } +} diff --git a/test/unit/helpers/fakeModelApi.ts b/test/unit/helpers/fakeModelApi.ts index fff3d84e..3c1e0c0c 100644 --- a/test/unit/helpers/fakeModelApi.ts +++ b/test/unit/helpers/fakeModelApi.ts @@ -65,6 +65,8 @@ export interface ScriptedReply { readonly garbage?: boolean /** Fail the fetch itself (network error) instead of answering. */ readonly networkError?: string + /** The socket code under that error, as Node's fetch keeps it in `cause` (M56). */ + readonly networkErrorCode?: string /** A keep-alive with empty data mid-stream and the OpenAI-style `data: [DONE]` at the end. */ readonly doneSentinel?: boolean } @@ -330,16 +332,23 @@ async function afterGate( if (signal?.aborted === true) { throw new DOMException('aborted', 'AbortError') } - const aborted = Promise.withResolvers() - const onAbort = () => { - aborted.reject(new DOMException('aborted', 'AbortError')) - } - signal?.addEventListener('abort', onAbort, { once: true }) + // Not `Promise.withResolvers`: the integration tests load this helper in + // VS Code 1.99, whose Node 20.18 lacks it (PLAN.md M62). + const listening = new AbortController() + const aborted = new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + reject(new DOMException('aborted', 'AbortError')) + }, + { once: true, signal: listening.signal }, + ) + }) try { - await Promise.race([gate, aborted.promise]) + await Promise.race([gate, aborted]) return response } finally { - signal?.removeEventListener('abort', onAbort) + listening.abort() } } @@ -458,7 +467,14 @@ export function fakeModelApi(): FakeModelApi { const reply = replies[Math.min(consumed, replies.length - 1)] ?? { text: 'ok' } consumed += 1 if (reply.networkError !== undefined) { - return Promise.reject(new TypeError(reply.networkError)) + const code = reply.networkErrorCode + return Promise.reject( + code === undefined + ? new TypeError(reply.networkError) + : new TypeError(reply.networkError, { + cause: Object.assign(new Error(`connect ${code}`), { code }), + }), + ) } if (reply.httpError !== undefined) { return Promise.resolve( diff --git a/test/unit/helpers/fakes.ts b/test/unit/helpers/fakes.ts index 88b0063e..dea5086b 100644 --- a/test/unit/helpers/fakes.ts +++ b/test/unit/helpers/fakes.ts @@ -9,7 +9,8 @@ import type { SettingsSource } from '../../../src/host/settings' import { EN } from '../../../src/shared/l10n/en' import { BASE_LOCALE } from '../../../src/shared/l10n/text' import type { HostToWebviewMessage, SettingsSnapshot } from '../../../src/shared/protocol' -import type { ChatSurface, WebviewHostContext } from '../../../src/host/views/webviewSetup' +import type { ChatSurface } from '../../../src/host/views/chatSurface' +import type { WebviewHostContext } from '../../../src/host/views/webviewSetup' import { EventEmitter, FakeUri, Uri } from '../mocks/vscode' function acceptMessage(_message: unknown): Thenable { @@ -48,7 +49,7 @@ export class FakeWebviewView implements vscode.WebviewView { export class FakeWebviewPanel implements vscode.WebviewPanel { public readonly webview = new FakeWebview() public readonly options: vscode.WebviewPanelOptions = {} - public iconPath?: vscode.IconPath + public iconPath?: NonNullable public viewColumn: vscode.ViewColumn | undefined = undefined public active = true public visible = true diff --git a/test/unit/helpers/modelApiBundle.ts b/test/unit/helpers/modelApiBundle.ts index 39594b76..6f1a5981 100644 --- a/test/unit/helpers/modelApiBundle.ts +++ b/test/unit/helpers/modelApiBundle.ts @@ -16,7 +16,8 @@ export function buildModelApiBundle(folder: string): string { bundle: true, platform: 'node', format: 'cjs', - target: 'node22', + // The extension host of the floor, VS Code 1.99 (scripts/build.mjs, PLAN.md M62). + target: 'node20.18', logLevel: 'silent', }) return file diff --git a/test/unit/helpers/networkFacts.ts b/test/unit/helpers/networkFacts.ts index 89d10bd1..e063fc5f 100644 --- a/test/unit/helpers/networkFacts.ts +++ b/test/unit/helpers/networkFacts.ts @@ -12,6 +12,8 @@ export const DEFAULT_NETWORK_FACTS: NetworkFacts = { isSystemCertificatesOn: true, isFetchSupportOn: true, isWebSocketSupportOn: true, + fetchRouting: 'routed', + webSocketRouting: 'routed', hasEnvironmentProxy: false, hasExtraCaCertificates: false, museProxySource: 'none', diff --git a/test/unit/helpers/paidGrants.ts b/test/unit/helpers/paidGrants.ts new file mode 100644 index 00000000..2eb73cbd --- /dev/null +++ b/test/unit/helpers/paidGrants.ts @@ -0,0 +1,25 @@ +// The agent's "Allow always in this workspace" store (M58, PLAN.md D48, D62) +// in memory, for tests that are not about its file. + +import type { PaidGrantStore } from '../../../src/acp/paid' +import type { PaidFeature } from '../../../src/shared/constants' + +export function memoryPaidGrants(): PaidGrantStore & { + readonly byFolder: Map> +} { + const byFolder = new Map>() + return { + byFolder, + read: (workspaceRoot) => byFolder.get(workspaceRoot) ?? new Set(), + add: (workspaceRoot, features) => { + byFolder.set(workspaceRoot, new Set([...(byFolder.get(workspaceRoot) ?? []), ...features])) + return Promise.resolve() + }, + forget: (features) => { + for (const [folder, grants] of byFolder) { + byFolder.set(folder, new Set([...grants].filter((feature) => !features.includes(feature)))) + } + return Promise.resolve() + }, + } +} diff --git a/test/unit/hostBridge.test.ts b/test/unit/hostBridge.test.ts new file mode 100644 index 00000000..29008eb0 --- /dev/null +++ b/test/unit/hostBridge.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { vsCodeHostBridge } from '../../src/webview/hostBridge' + +// M61 (PLAN.md D60): the webview reaches VS Code only through this bridge. + +function fakeApi() { + return { postMessage: vi.fn(), getState: vi.fn(() => ({ sessionId: 's-1' })), setState: vi.fn() } +} + +describe('vsCodeHostBridge', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('acquires the VS Code API once and posts, reads and saves through it', () => { + const api = fakeApi() + const acquire = vi.fn(() => api) + vi.stubGlobal('acquireVsCodeApi', acquire) + + const host = vsCodeHostBridge(window) + host.post({ type: 'ready' }) + host.saveState({ sessionId: 's-2' }) + + expect(acquire).toHaveBeenCalledTimes(1) + expect(api.postMessage).toHaveBeenCalledWith({ type: 'ready' }) + expect(host.savedState()).toEqual({ sessionId: 's-1' }) + expect(api.setState).toHaveBeenCalledWith({ sessionId: 's-2' }) + }) + + it("takes the host's messages from the window it is given", () => { + vi.stubGlobal('acquireVsCodeApi', () => fakeApi()) + const host = vsCodeHostBridge(window) + const received: unknown[] = [] + const onMessage = (event: MessageEvent) => { + received.push(event.data) + } + + host.messages.addEventListener('message', onMessage) + window.dispatchEvent(new MessageEvent('message', { data: { type: 'conversationCleared' } })) + host.messages.removeEventListener('message', onMessage) + window.dispatchEvent(new MessageEvent('message', { data: 'after' })) + + expect(received).toEqual([{ type: 'conversationCleared' }]) + }) +}) diff --git a/test/unit/modelApiHost.test.ts b/test/unit/modelApiHost.test.ts index 6c690f99..83f25410 100644 --- a/test/unit/modelApiHost.test.ts +++ b/test/unit/modelApiHost.test.ts @@ -212,6 +212,8 @@ function setup( const paidUses: { readonly feature: PaidFeature; readonly units: number }[] = [] // What the paid-use popup was asked (M58), in order, and whether it had to ask. const paidRequests: { readonly request: PaidUseRequest; readonly requiresAsking: boolean }[] = [] + // The conversation each question was asked in, in the same order. + const paidSessions: string[] = [] const subagentUsage: { readonly modelId: string readonly inputTokens: number @@ -280,9 +282,11 @@ function setup( }), mcpServers: options.mcpServers, ideTools: options.ideTools, - allowsPaidUse: async (request, requiresAsking) => { + allowsPaidUse: async (request, requiresAsking, sessionId) => { paidRequests.push({ request, requiresAsking }) - return await (options.allowsPaidUse?.(request, requiresAsking) ?? Promise.resolve(true)) + paidSessions.push(sessionId) + return await (options.allowsPaidUse?.(request, requiresAsking, sessionId) ?? + Promise.resolve(true)) }, isPaidUseRemembered: (feature) => options.remembered?.includes(feature) === true, noteSubagentUsage: (modelId, usage) => { @@ -303,6 +307,7 @@ function setup( shellCalls: io.shellCalls, paidUses, paidRequests, + paidSessions, advanceClock: (ms: number) => { clock += ms }, @@ -5716,6 +5721,60 @@ describe('ModelApiSession subagents (M48)', () => { }) }) + it('asks each paid use in its conversation, a child’s in its parent’s (M58, PLAN.md D62)', async () => { + const t = setupSubagents({ + paid: ['webSearch', 'imageGeneration'], + allowsPaidUse: (request) => Promise.resolve(request.feature !== 'imageGeneration'), + }) + const other = await startSession(t) + await answerFirst(t, other.session, other.turnDone) + const { session } = await startSession(t) + t.api.script( + { + calls: [ + { + name: 'subagent_spawn', + arguments: '{"role":"designer","objective":"Draw the logo"}', + callId: 'spawn', + }, + ], + }, + { text: 'Child ready.' }, + { text: 'Parent continues.' }, + ) + await session.sendTurn([{ type: 'text', text: 'delegate the logo' }]) + await waitForChildReady(t, session) + t.api.script( + { + calls: [ + { + name: 'generate_image', + arguments: '{"path":"logo.png","prompt":"a logo"}', + callId: 'child_image', + }, + ], + }, + { text: 'Child finished.' }, + ) + await session.messageSubagent('subagent-1', 'Draw it now', true) + await paidPopupAsked(t, 5) + // Web search in each conversation, the spawn, the owner's follow-up, then + // the child's image: the host serves both conversations, and the child + // asks in its parent's. + expect(t.paidRequests.map(({ request }) => request.feature)).toEqual([ + 'webSearch', + 'webSearch', + 'subagents', + 'subagents', + 'imageGeneration', + ]) + expect(t.paidSessions).toEqual([ + other.session.sessionId, + ...Array.from({ length: 4 }, () => session.sessionId), + ]) + expect(other.session.sessionId).not.toBe(session.sessionId) + }) + it('reuses the same child when a spawn command id is retried', async () => { const t = setupSubagents() const { session, events } = await startApprovedSubagentSession(t) diff --git a/test/unit/networkFailure.test.ts b/test/unit/networkFailure.test.ts index 20468fe4..f0f7dc0b 100644 --- a/test/unit/networkFailure.test.ts +++ b/test/unit/networkFailure.test.ts @@ -122,3 +122,33 @@ describe('describeNetworkFailure (M56, PLAN.md D43)', () => { ) }) }) + +describe('the ACP agent’s advice (PLAN.md D62, Q66)', () => { + it('names the agent’s environment, never VS Code’s settings, for the same failures', async () => { + const refused = await refusedFetch() + const cases: readonly [unknown, string, string][] = [ + [capturedCertificateFailure(), UI_TEXT.acpNetworkUntrustedCertificate, 'NODE_EXTRA_CA_CERTS'], + [capturedProxyFailure(407), UI_TEXT.acpNetworkProxyCredentials, 'HTTPS_PROXY'], + [refused, UI_TEXT.acpNetworkUnreachable, 'NODE_USE_ENV_PROXY=1'], + ] + for (const [error, advice, variable] of cases) { + const message = networkFailureMessage(error, 'agent') + expect(message).toBe(`${advice} (${describeNetworkFailure(error).detail})`) + expect(message).toContain(variable) + expect(message).not.toMatch(/http\.(proxy|systemCertificates)|VS Code/) + } + }) + + it('shares the proxy’s refusal, which names no setting, and leaves the extension’s advice as it was', () => { + expect(networkFailureMessage(capturedProxyFailure(403), 'agent')).toBe( + networkFailureMessage(capturedProxyFailure(403)), + ) + expect(networkFailureMessage(capturedCertificateFailure(), 'vscode')).toBe( + networkFailureMessage(capturedCertificateFailure()), + ) + expect(networkFailureMessage(capturedProxyFailure(407), 'vscode')).toContain( + UI_TEXT.networkProxyCredentials, + ) + expect(networkFailureMessage(new Error('odd'), 'agent')).toBe('odd') + }) +}) diff --git a/test/unit/networkPosture.test.ts b/test/unit/networkPosture.test.ts index 9ba97499..091ea6aa 100644 --- a/test/unit/networkPosture.test.ts +++ b/test/unit/networkPosture.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + hostRouting, liveFetch, managedConfiguration, type MuseNetworkPosture, @@ -13,6 +14,25 @@ const MUSE: MuseNetworkPosture = { hasCertificateOverride: () => false, } +function unused(): undefined { + return undefined +} + +// The extension host's globals as each VS Code leaves them (proxyResolver.ts +// at each tag, M62): 1.112.0 on patches both and marks each; 1.101 to 1.111 +// patch `fetch` only, with Node 22's own WebSocket; 1.99 and 1.100 run Node +// 20, which has no WebSocket; an editor without VS Code's extension host +// marks neither. +const VSCODE_1_112 = { + fetch: unused, + WebSocket: unused, + __vscodeOriginalFetch: unused, + __vscodeOriginalWebSocket: unused, +} +const VSCODE_1_101 = { fetch: unused, WebSocket: unused, __vscodeOriginalFetch: unused } +const VSCODE_1_99 = { fetch: unused, __vscodeOriginalFetch: unused } +const UNPATCHED_HOST = { fetch: unused, WebSocket: unused } + /** VS Code's `http` section as a settings source. */ function httpSettings(values: Record) { return { get: (section: string): unknown => values[section] } @@ -39,9 +59,35 @@ describe('readProxySettings (M56, PLAN.md D43)', () => { }) }) +describe('hostRouting (M62, PLAN.md D43)', () => { + it('claims a route only where the extension host marked its proxy-aware global', () => { + expect(hostRouting(VSCODE_1_112, 'fetch')).toBe('routed') + expect(hostRouting(VSCODE_1_112, 'webSocket')).toBe('routed') + expect(hostRouting(VSCODE_1_101, 'fetch')).toBe('routed') + expect(hostRouting(VSCODE_1_101, 'webSocket')).toBe('notRouted') + expect(hostRouting(VSCODE_1_99, 'fetch')).toBe('routed') + expect(hostRouting(VSCODE_1_99, 'webSocket')).toBe('absent') + expect(hostRouting(UNPATCHED_HOST, 'fetch')).toBe('notRouted') + expect(hostRouting(UNPATCHED_HOST, 'webSocket')).toBe('notRouted') + }) +}) + describe('readNetworkFacts (M56, PLAN.md D43)', () => { it('reads VS Code’s defaults when nothing is set', () => { - expect(readNetworkFacts(httpSettings({}), {}, 'linux', MUSE)).toEqual(DEFAULT_NETWORK_FACTS) + expect(readNetworkFacts(httpSettings({}), {}, 'linux', MUSE, VSCODE_1_112)).toEqual( + DEFAULT_NETWORK_FACTS, + ) + }) + + it('says which globals this editor routes, whatever the settings say', () => { + expect(readNetworkFacts(httpSettings({}), {}, 'linux', MUSE, VSCODE_1_101)).toMatchObject({ + isWebSocketSupportOn: true, + fetchRouting: 'routed', + webSocketRouting: 'notRouted', + }) + expect(readNetworkFacts(httpSettings({}), {}, 'linux', MUSE, VSCODE_1_99)).toMatchObject({ + webSocketRouting: 'absent', + }) }) it('states a corporate setup as yes/no and counts, never the proxy itself', () => { @@ -59,6 +105,7 @@ describe('readNetworkFacts (M56, PLAN.md D43)', () => { { https_proxy: 'https://proxy.corp:8443', NODE_EXTRA_CA_CERTS: '/etc/corp-root.pem' }, 'linux', { proxySource: () => 'vscode', hasCertificateOverride: () => true }, + VSCODE_1_112, ) expect(facts).toEqual({ isProxySet: true, @@ -69,6 +116,8 @@ describe('readNetworkFacts (M56, PLAN.md D43)', () => { isSystemCertificatesOn: false, isFetchSupportOn: false, isWebSocketSupportOn: false, + fetchRouting: 'routed', + webSocketRouting: 'routed', hasEnvironmentProxy: true, hasExtraCaCertificates: true, museProxySource: 'vscode', @@ -88,6 +137,7 @@ describe('readNetworkFacts (M56, PLAN.md D43)', () => { { Https_Proxy: 'http://p:1', node_extra_ca_certs: String.raw`C:\corp.pem` }, 'win32', MUSE, + VSCODE_1_112, ) expect(facts).toMatchObject({ isProxySet: false, @@ -98,7 +148,9 @@ describe('readNetworkFacts (M56, PLAN.md D43)', () => { hasExtraCaCertificates: true, }) // An empty variable is not a proxy. - expect(readNetworkFacts(httpSettings({}), { HTTPS_PROXY: '' }, 'linux', MUSE)).toMatchObject({ + expect( + readNetworkFacts(httpSettings({}), { HTTPS_PROXY: '' }, 'linux', MUSE, VSCODE_1_112), + ).toMatchObject({ hasEnvironmentProxy: false, }) }) diff --git a/test/unit/paidConsent.test.ts b/test/unit/paidConsent.test.ts index d236ba1b..6c2644c4 100644 --- a/test/unit/paidConsent.test.ts +++ b/test/unit/paidConsent.test.ts @@ -34,6 +34,23 @@ function consentWith( const SEARCH: PaidUseRequest = { feature: 'webSearch' } describe('PaidUseConsent (M58)', () => { + it('lets an "always" it cannot keep go ahead once, and says so', async () => { + const log = new FakeLogOutputChannel() + const consent = new PaidUseConsent({ + isOn: () => true, + canRemember: () => true, + readGrants: () => new Set(), + writeGrants: () => Promise.reject(new Error('storage is full')), + ask: () => Promise.resolve('always'), + log, + }) + await expect(consent.allows(SEARCH)).resolves.toBe(true) + expect(log.warn).toHaveBeenCalledWith( + 'Paid use of webSearch: "always" could not be kept, so it is allowed once: storage is full', + ) + expect(log.info).toHaveBeenLastCalledWith('Paid use of webSearch: allowed once') + }) + it('refuses a feature that is off without asking', async () => { const t = consentWith({ on: [] }) await expect(t.consent.allows(SEARCH)).resolves.toBe(false) diff --git a/test/unit/supportReport.test.ts b/test/unit/supportReport.test.ts index c4a2eaf0..14744e77 100644 --- a/test/unit/supportReport.test.ts +++ b/test/unit/supportReport.test.ts @@ -144,6 +144,8 @@ describe('renderSupportReport', () => { isSystemCertificatesOn: false, isFetchSupportOn: false, isWebSocketSupportOn: true, + fetchRouting: 'routed', + webSocketRouting: 'routed', hasEnvironmentProxy: true, hasExtraCaCertificates: true, museProxySource: 'vscode', @@ -167,6 +169,28 @@ describe('renderSupportReport', () => { ).toContain('muse serve: proxy from its environment;') }) + // M62 (PLAN.md D43): below VS Code 1.112 an extension's WebSocket is not + // routed, and on Node 20 there is none; the settings do not change that. + it('never claims a route the editor does not give a global', () => { + const olderVsCode = renderSupportReport({ + ...base, + network: { ...base.network, webSocketRouting: 'notRouted' }, + }) + expect(olderVsCode).toContain( + "extension requests through VS Code's network support: fetch yes, WebSocket no (this editor does not route it; VS Code does from 1.112)", + ) + const node20 = renderSupportReport({ + ...base, + network: { ...base.network, webSocketRouting: 'absent' }, + }) + expect(node20).toContain('fetch yes, WebSocket none in this extension host') + const otherEditor = renderSupportReport({ + ...base, + network: { ...base.network, fetchRouting: 'notRouted', webSocketRouting: 'notRouted' }, + }) + expect(otherEditor).toContain('fetch no (this editor does not route it), WebSocket no (') + }) + it('shows captured status fields and omits unrecognized lines that may contain secrets', () => { const managed = renderSupportReport({ ...base, diff --git a/test/unit/webviewSetup.test.ts b/test/unit/webviewSetup.test.ts index 5fcca45d..2f4687f0 100644 --- a/test/unit/webviewSetup.test.ts +++ b/test/unit/webviewSetup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { type ChatSurface, configureWebview } from '../../src/host/views/webviewSetup' +import type { ChatSurface } from '../../src/host/views/chatSurface' +import { configureWebview } from '../../src/host/views/webviewSetup' import { WEBVIEW_L10N_ELEMENT_ID } from '../../src/shared/constants' import { EN } from '../../src/shared/l10n/en' import { FakeWebview, fakeHostContext, testSettings } from './helpers/fakes' diff --git a/tsconfig.json b/tsconfig.json index b21e791c..ba977d1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,10 @@ { // Extension host project: src/extension.ts, src/host/**, src/core/**, src/shared/**. // Runs inside VS Code's Node-based extension host; no DOM types here. + // ES2023: the floor's extension host is Node 20 (VS Code 1.99, PLAN.md M62). "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ES2024"], + "lib": ["ES2023"], "types": ["node", "vscode"] }, "include": ["src/**/*.ts", "vitest.config.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 1f450c82..0344c2d4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,8 +34,9 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}'], // Entry points are exercised by the integration run (a real VS Code), // not by unit tests; the process adapter that spawns `muse serve` is - // covered by the e2e suite against a real child process (test/e2e). - exclude: ['src/extension.ts', 'src/webview/main.tsx', 'src/**/*.d.ts'], + // covered by the e2e suite against a real child process (test/e2e), + // and the ACP agent's entry by the e2e suite over its stdio (M63). + exclude: ['src/extension.ts', 'src/webview/main.tsx', 'src/runtime/main.ts', 'src/**/*.d.ts'], thresholds: COVERAGE_THRESHOLDS, reporter: ['text', 'lcov'], },