diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f53db6a6..a8db269c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -217,3 +217,27 @@ josephyoung pr mbazso pr AJM10565 pr + +DanielThomas pr + +MichaelYochpaz pr + +stephanmck pr + +rolfvreijdenberger pr + +psoukie pr + +vastxie pr + +ItsumoSeito pr + +davidlifschitz pr + +vdxz pr + +dangooddd pr + +Mearman pr + +dodiego pr diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index cf50ffde..43cb73c5 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -11,6 +11,8 @@ body: Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + **Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded. + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/package-report.yml b/.github/ISSUE_TEMPLATE/package-report.yml new file mode 100644 index 00000000..846e25ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-report.yml @@ -0,0 +1,49 @@ +name: Package Report +description: Report a problematic Pi package listed on pi.dev +labels: ["package-report"] +body: + - type: markdown + attributes: + value: | + Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead. + + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: input + id: package-name + attributes: + label: Package name + description: The npm package name from pi.dev. + placeholder: "@scope/package" + validations: + required: true + + - type: input + id: package-version + attributes: + label: Version + description: The package version shown on pi.dev. + placeholder: "0.1.0" + validations: + required: false + + - type: dropdown + id: report-type + attributes: + label: What are you reporting? + options: + - Malicious or unsafe behavior + - Impersonation + - Trademark / TOS Violations + validations: + required: true + + - type: textarea + id: details + attributes: + label: Details + description: Describe the concern and include links, logs, or screenshots if helpful. + validations: + required: true diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ccc4eab8..355ad1cd 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -10,24 +10,30 @@ on: description: 'Tag to build (e.g., v0.12.0)' required: true type: string + source_ref: + description: 'Source ref to build/publish (defaults to tag; use only for release recovery)' + required: false + type: string -permissions: - contents: write +permissions: {} jobs: build: runs-on: ubuntu-latest + permissions: + contents: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ env.SOURCE_REF }} persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.10 @@ -45,38 +51,86 @@ jobs: run: | VERSION="${RELEASE_TAG}" VERSION="${VERSION#v}" # Remove 'v' prefix - - # Extract changelog section for this version - cd packages/coding-agent - awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md - - # If empty, use a default message - if [ ! -s /tmp/release-notes.md ]; then - echo "Release ${VERSION}" > /tmp/release-notes.md - fi + node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md - name: Create GitHub Release and upload binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd packages/coding-agent/binaries - - # Create release with changelog notes (or update if exists) - gh release create "${RELEASE_TAG}" \ - --title "${RELEASE_TAG}" \ - --notes-file /tmp/release-notes.md \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - 2>/dev/null || \ - gh release upload "${RELEASE_TAG}" \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - --clobber + + release_assets=( + pi-darwin-arm64.tar.gz + pi-darwin-x64.tar.gz + pi-linux-x64.tar.gz + pi-linux-arm64.tar.gz + pi-windows-x64.zip + pi-windows-arm64.zip + ) + sha256sum "${release_assets[@]}" > SHA256SUMS + release_assets+=(SHA256SUMS) + + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release edit "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md + gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber + else + gh release create "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md \ + "${release_assets[@]}" + fi + + publish-npm: + runs-on: ubuntu-latest + needs: build + environment: npm-publish + permissions: + contents: read + id-token: write + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: npm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep + sudo ln -s $(which fdfind) /usr/local/bin/fd + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Check + run: npm run check + + - name: Test + run: npm test + + - name: Verify release artifacts are committed + run: git diff --exit-code + + - name: Upgrade npm for trusted publishing + run: | + npm install -g npm@11.16.0 --ignore-scripts + npm --version + + - name: Publish npm packages + run: node scripts/publish.mjs diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 62663940..d2bf830e 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -111,9 +111,17 @@ jobs: body: message, }); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['untriaged'], + }); + await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, state: 'closed', + state_reason: 'not_planned', }); diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml new file mode 100644 index 00000000..ebe5500c --- /dev/null +++ b/.github/workflows/issue-triage-labels.yml @@ -0,0 +1,137 @@ +name: Issue Triage Labels + +on: + issues: + types: [reopened, labeled] + +jobs: + update-labels: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Update triage labels + uses: actions/github-script@v7 + with: + script: | + const UNTRIAGED_LABEL = 'untriaged'; + const NO_ACTION_LABEL = 'no-action'; + const LAST_READ_LABEL = 'last-read'; + const TO_DISCUSS_LABEL = 'to-discuss'; + const INPROGRESS_LABEL = 'inprogress'; + + function issueHasLabel(issue, labelName) { + return (issue.labels ?? []).some((label) => label.name === labelName); + } + + async function removeLabelIfPresent(issueNumber, issue, labelName) { + if (!issueHasLabel(issue, labelName)) { + console.log(`Issue #${issueNumber} does not have ${labelName}`); + return; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName, + }); + console.log(`Removed ${labelName} from #${issueNumber}`); + } catch (error) { + if (error.status === 404) { + console.log(`Label ${labelName} was already absent from #${issueNumber}`); + return; + } + throw error; + } + } + + if (context.payload.action === 'reopened') { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL); + return; + } + + if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) { + console.log('Not a last-read label event'); + return; + } + + const currentIssueNumber = context.issue.number; + const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: LAST_READ_LABEL, + per_page: 100, + }); + + const previousIssueNumbers = lastReadIssues + .filter((issue) => !issue.pull_request) + .map((issue) => issue.number) + .filter((issueNumber) => issueNumber !== currentIssueNumber); + + if (previousIssueNumbers.length === 0) { + console.log('No previous last-read issue found'); + return; + } + + const previousIssueNumber = Math.max(...previousIssueNumbers); + if (currentIssueNumber <= previousIssueNumber) { + console.log( + `Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`, + ); + return; + } + + const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: UNTRIAGED_LABEL, + per_page: 100, + }); + + const issuesToMark = untriagedIssues + .filter((issue) => !issue.pull_request) + .filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber) + .sort((a, b) => a.number - b.number); + + if (issuesToMark.length === 0) { + console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`); + return; + } + + for (const issue of issuesToMark) { + if (issueHasLabel(issue, TO_DISCUSS_LABEL)) { + console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`); + } else { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [NO_ACTION_LABEL], + }); + console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + } + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + console.log(`Closed #${issue.number} as not planned`); + + await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL); + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: UNTRIAGED_LABEL, + }); + console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`); + } diff --git a/.github/workflows/remove-inprogress-on-close.yml b/.github/workflows/remove-inprogress-on-close.yml new file mode 100644 index 00000000..e94b10e2 --- /dev/null +++ b/.github/workflows/remove-inprogress-on-close.yml @@ -0,0 +1,31 @@ +name: Remove In Progress Label On Close + +on: + issues: + types: [closed] + +jobs: + remove-label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove inprogress label + uses: actions/github-script@v7 + with: + script: | + const labelName = 'inprogress'; + const labels = context.payload.issue.labels ?? []; + const hasLabel = labels.some((label) => label.name === labelName); + + if (!hasLabel) { + console.log(`Issue does not have ${labelName} label`); + return; + } + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: labelName, + }); diff --git a/AGENTS.md b/AGENTS.md index 144d2c60..d653442f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") - Technical prose only, be direct - When the user asks a question, answer it first before making edits or running implementation commands. +- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. ## Code Quality @@ -20,7 +21,7 @@ - Always ask before removing functionality or code that appears intentional. - Do not preserve backward compatibility unless the user asks for it. - Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable. -- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead. +- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes. ## Commands @@ -51,6 +52,7 @@ Committing: - Stage explicit paths (`git add `); never `git add -A` / `git add .`. - Before committing, run `git status` and verify you are only staging your files. - `packages/ai/src/models.generated.ts` may always be included alongside your files. +- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: (optionally multiple lines)`. Message is informative and concise. Never run (destroys other agents' work or bypasses checks): @@ -66,6 +68,12 @@ If rebase conflicts occur: See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar). +When reviewing PRs: + +- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks. +- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches. +- If you need PR file contents, fetch/read them into temporary files or use `git show :` without switching branches. + When creating issues: - Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply. @@ -119,40 +127,35 @@ Attribution: ```bash npm run release:local -- --out /tmp/pi-local-release --force cd /tmp + + # Node package install smoke tests /tmp/pi-local-release/node/pi --help /tmp/pi-local-release/node/pi --version + /tmp/pi-local-release/node/pi --list-models + /tmp/pi-local-release/node/pi -p "Say exactly: ok" /tmp/pi-local-release/node/pi + + # Bun binary smoke tests /tmp/pi-local-release/bun/pi --help /tmp/pi-local-release/bun/pi --version + /tmp/pi-local-release/bun/pi --list-models + /tmp/pi-local-release/bun/pi -p "Say exactly: ok" + /tmp/pi-local-release/bun/pi ``` - Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk. + Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. -3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: - - ``` - Before I run the release script, read this carefully: - - - `npm publish` uses WebAuthn 2FA. - - A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits. - - You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue. - - This may happen more than once during the release. - - Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do. - ``` - - Do not proceed to step 4 until the user explicitly confirms. - -4. **Run the release script**: +3. **Run the release script**: ```bash - npm run release:patch # fixes + additions - npm run release:minor # breaking changes + PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions + PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes ``` - Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own. + Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push. -5. **After publish succeeds**: - - Add fresh `## [Unreleased]` sections to package changelogs. - - Commit with `Add [Unreleased] section for next cycle`. - - Push `main` and the release tag. + The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. + +4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. + +5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. ## User Override diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b0c38322 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to pi + +This guide exists to save both sides time. + +## Philosophy + +First things first: **pi's core is minimal**. + +If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected. + +Pi's core exists to be minimal and to be extensible so that it can be influenced and manipulated by extensions. Even hook points for extensions however should be well considered and discussed to avoid adding unmaintainable bloat and complex interactions. + +## The One Rule + +**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed. + +Using AI to write code is fine. Submitting AI-generated slop without understanding it is not. + +If you use an agent, run it from the `pi` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file. + +## Contribution Gate + +All issues and PRs from new contributors are auto-closed by default. + +Issues submitted Friday through Sunday are not guaranteed to be reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx + +Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply. + +Approval happens through maintainer replies on issues: + +- `lgtmi`: your future issues will not be auto-closed +- `lgtm`: your future issues and PRs will not be auto-closed + +`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs. + +## Quality Bar For Issues + +If you open an issue, you must use one of the two GitHub issue templates. + +If you open an issue, keep it short, concrete, and worth reading. + +- Keep it concise. If it does not fit on one screen, it is too long. +- Write in your own voice (do not use an LLM to generate text, if you must, follow up with a clearly AI labeled comment). +- State the bug or request clearly. +- Explain why it matters. +- If you want to implement the change yourself, say so. + +If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`. + +## Blocking + +If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked. + +If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies. + +## Before Submitting a PR + +Do not open a PR unless you have already been approved with `lgtm`. + +Before submitting a PR: + +```bash +npm run check +./test.sh +``` + +Both must pass. + +Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers. + +If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests. + +## Questions? + +Ask on [Discord](https://discord.com/invite/nKXTsAcmbT). + +## FAQ + +### Why are new issues and PRs auto-closed? + +pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar. + +### Why are weekend issues lower priority? + +We triage the tracker during working hours. That means more issues can accumulate over the weekend. Anything submitted Friday through Sunday may be missed or given lower priority in the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs. + +### Why do some issues get no reply? + +A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable. + +### Why not let AI triage everything? + +AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate. + +### Is this hostile to contributors? + +No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not. + +## Where can I learn about plans? + +Earendil uses RFCs to discuss larger changes. Not all of them are public, but +quite a few are. They can be found at [rfc.earendil.com](https://rfc.earendil.com/keyword/pi/). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ddd75e14 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,87 @@ +# Security Policy + +This document should guide you about understanding the security concept behind +Pi and also where the boundaries are. + +In general Pi is a coding agent that runs locally within the security boundary +of the user that is running it. It's the responsibiltiy of the user to monitor +its operations or to contain it within a container, virtual machine or other +Sandbox solution. + +Pi treats the local user account and files writable by that account as inside +the same trust boundary as the Pi process itself. If an attacker can modify files +under the user's home directory, workspace, shell startup files, environment, or +Pi configuration, they can generally influence Pi or other local developer tools. +Reports that depend on such prior local write access are not security +vulnerabilities unless they demonstrate how Pi grants that write access or crosses +an operating-system privilege boundary. + +Pi relies on users installing trustworthy extensions and loading trustworthy +skills and only to use pi within trusted repositories. This is because files +like `AGENTS.md` or instructions in comments can be used to prompt inject the +coding agent trivially and this cannot be protected against. + +## Reporting a Vulnerability + +If you believe you found a security vulnerability in pi or another package in +this repository, please report it privately by either: + +- Emailing `security@earendil.com`, or +- Opening a private report through GitHub Security Advisories for this repository + +Please include: + +- A description of the issue and its impact +- Steps to reproduce, proof of concept, or relevant logs +- Affected package, version, commit, or configuration +- Any known mitigations + +Do not open a public issue for security-sensitive reports. We will review +reports and coordinate disclosure as appropriate. + +## Scope + +Security issues in the distributed packages, command-line tools, APIs, and +repository code are in scope as well as earendil operated infrastricture +on `pi.dev`. + +## Out Of Scope + +- Local code execution or sandboxing behavior (the Pi coding agent intentionally does not have a sandbox) +- Behavior of pi extensions or skills installed by the user +- Risks from working in untrusted repositories +- Risks from installing untrusted extensions, skills, packages, or tools +- Isuses caused by non trustworthy MITM proxies +- Public internet exposure of a Pi installation +- Prompt injection attacks +- Exposed secrets that are third-party/user-controlled credentials +- Reports requiring the ability to create, modify, delete, or replace files, + directories, symlinks, environment variables, shell configuration, or other + user-controlled local state on the target machine. This includes `~/.pi`, + `~/.pi/agent/models.json`, workspace files, `AGENTS.md`, skills, extensions, + extension configuration, dotfiles, and files synchronized through NFS, roaming + profiles, or dotfile managers, unless the report shows how Pi itself grants + that access. +- Issues caused by intentionally weakened user configuration. +- Resource/DOS claims that require trusted local input/config against the pi coding agent. +- Reports about malicious model output. +- User-approved or user-initiated local actions presented as vulnerabilities. + +## Notes for Reporters + +The most useful reports show a current, reproducible security boundary bypass +with demonstrated impact. Reports that only show expected local-agent behavior, +prompt injection, or a malicious trusted extension/skill are not security +vulnerabilities under this model. + +For example, a report showing that malicious contents written to a trusted Pi +configuration file cause Pi to execute commands, load attacker-controlled tools, +send credentials to an attacker-controlled endpoint, or otherwise change behavior +is out of scope. + +When possible, include the exact affected path, package version or commit SHA, +configuration, and a proof of concept against the latest release or latest +`main`. For dependency reports, include evidence that the shipped dependency is +affected and that the issue is reachable through Pi. For exposed-secret reports, +include evidence that the credential is owned by Earendil or grants access to +Earendil-operated infrastructure or services. diff --git a/package-lock.json b/package-lock.json index 3e936140..e4505e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,18 @@ "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", - "packages/coding-agent/examples/extensions/sandbox" + "packages/coding-agent/examples/extensions/sandbox", + "packages/coding-agent/examples/extensions/gondolin" ], + "dependencies": { + "@mistralai/mistralai": "2.2.6" + }, "devDependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26", "@biomejs/biome": "2.3.5", "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "husky": "9.1.7", "jiti": "2.7.0", "shx": "0.4.0", @@ -30,20 +34,6 @@ "node": ">=22.19.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@anthropic-ai/sandbox-runtime": { "version": "0.0.26", "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz", @@ -722,6 +712,78 @@ "node": ">=14.21.3" } }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", + "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@earendil-works/gondolin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", + "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "license": "Apache-2.0", + "dependencies": { + "cbor2": "^2.3.0", + "node-forge": "^1.3.3", + "ssh2": "^1.17.0", + "undici": "^6.21.0" + }, + "bin": { + "gondolin": "dist/bin/gondolin.js" + }, + "engines": { + "node": ">=23.6.0" + }, + "optionalDependencies": { + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", + "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", + "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@earendil-works/pi-agent-core": { "resolved": "packages/agent", "link": true @@ -738,10 +800,44 @@ "resolved": "packages/tui", "link": true }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -756,9 +852,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -773,9 +869,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -790,9 +886,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -807,9 +903,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -824,9 +920,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -841,9 +937,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -858,9 +954,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -875,9 +971,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -892,9 +988,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -909,9 +1005,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -926,9 +1022,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -943,9 +1039,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -960,9 +1056,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -977,9 +1073,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -994,9 +1090,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1011,9 +1107,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1028,9 +1124,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1045,9 +1141,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1062,9 +1158,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1079,9 +1175,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1096,9 +1192,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1113,9 +1209,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1130,9 +1226,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1147,9 +1243,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1164,9 +1260,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1204,130 +1300,6 @@ } } }, - "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-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "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/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1357,31 +1329,31 @@ } }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", "cpu": [ "arm64" ], @@ -1395,9 +1367,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1380,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", "cpu": [ "x64" ], @@ -1424,12 +1396,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1440,12 +1415,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1456,12 +1434,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1472,12 +1453,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1488,12 +1472,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1504,9 +1491,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", "cpu": [ "arm64" ], @@ -1520,9 +1507,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", "cpu": [ "x64" ], @@ -1536,14 +1523,42 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodable/entities": { @@ -1596,17 +1611,34 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", "engines": { "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pondwader/socks5-server": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", @@ -1632,9 +1664,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { @@ -1652,12 +1684,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1676,24 +1702,10 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1702,12 +1714,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1716,12 +1731,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1730,26 +1748,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1758,12 +1765,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1772,194 +1782,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1968,12 +1919,34 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1982,26 +1955,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -2010,21 +1972,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", @@ -2152,6 +2110,24 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2249,6 +2225,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", @@ -2366,155 +2349,6 @@ "win32" ] }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -2522,13 +2356,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "dev": true, - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2538,30 +2365,13 @@ "node": ">= 14" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "safer-buffer": "~2.1.0" } }, "node_modules/assertion-error": { @@ -2574,18 +2384,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2615,6 +2413,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2698,14 +2505,13 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, "engines": { - "node": ">=8" + "node": ">=10.0.0" } }, "node_modules/canvas": { @@ -2723,21 +2529,16 @@ "node": "^18.12.0 || >= 20.9.0" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, + "node_modules/cbor2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", + "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", "license": "MIT", "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" + "@cto.af/wtf8": "0.0.5" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/chalk": { @@ -2752,16 +2553,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -2769,26 +2560,6 @@ "dev": true, "license": "ISC" }, - "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/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2798,6 +2569,27 @@ "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/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2854,16 +2646,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -2893,13 +2675,6 @@ "node": ">=0.3.1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2909,13 +2684,6 @@ "safe-buffer": "^5.0.1" } }, - "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/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2936,17 +2704,10 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2957,32 +2718,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -3213,36 +2974,6 @@ "node": ">=8" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3582,16 +3313,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3669,21 +3390,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -3698,22 +3404,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3773,6 +3463,279 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -3785,13 +3748,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", @@ -3811,18 +3767,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3840,15 +3784,15 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/merge2": { @@ -3935,6 +3879,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -4026,6 +3977,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -4049,6 +4009,20 @@ "node": ">=4" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4109,13 +4083,6 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, - "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", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -4176,16 +4143,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pi-extension-custom-provider-anthropic": { "resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic", "link": true @@ -4194,6 +4151,10 @@ "resolved": "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "link": true }, + "node_modules/pi-extension-gondolin": { + "resolved": "packages/coding-agent/examples/extensions/gondolin", + "link": true + }, "node_modules/pi-extension-sandbox": { "resolved": "packages/coding-agent/examples/extensions/sandbox", "link": true @@ -4223,9 +4184,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4243,7 +4204,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4300,24 +4261,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -4440,58 +4400,40 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4536,11 +4478,16 @@ ], "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==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4571,9 +4518,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4688,6 +4635,23 @@ "node": ">=0.10.0" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4695,13 +4659,6 @@ "dev": true, "license": "MIT" }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4712,64 +4669,6 @@ "safe-buffer": "~5.2.0" } }, - "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/string-width-cjs": { - "name": "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/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/strip-ansi-cjs": { - "name": "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/strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -4790,26 +4689,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -4865,100 +4744,6 @@ "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/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/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4966,17 +4751,10 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5021,36 +4799,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5108,6 +4856,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", @@ -5129,9 +4883,9 @@ } }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -5151,18 +4905,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5178,9 +4931,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -5193,15 +4947,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -5225,531 +4982,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -5763,92 +4995,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -5890,25 +5036,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5917,9 +5044,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5987,19 +5114,19 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.75.4", + "version": "0.79.9", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" }, "devDependencies": { "@types/node": "24.12.4", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" @@ -6015,6 +5142,204 @@ "undici-types": "~7.16.0" } }, + "packages/agent/node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "packages/agent/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "packages/agent/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/agent/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/agent/node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "packages/agent/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/agent/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/agent/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/agent/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6022,15 +5347,134 @@ "dev": true, "license": "MIT" }, + "packages/agent/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/agent/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.75.4", + "version": "0.79.9", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", @@ -6043,7 +5487,7 @@ "devDependencies": { "@types/node": "24.12.4", "canvas": "3.2.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" @@ -6059,6 +5503,176 @@ "undici-types": "~7.16.0" } }, + "packages/ai/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/ai/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/ai/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/ai/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/ai/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/ai/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/ai/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/ai/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6066,14 +5680,104 @@ "dev": true, "license": "MIT" }, - "packages/coding-agent": { - "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "packages/ai/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/coding-agent": { + "name": "@earendil-works/pi-coding-agent", + "version": "0.79.9", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6085,8 +5789,9 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -6099,38 +5804,46 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" } }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.75.4", + "version": "0.79.9", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.75.4" + "version": "0.79.9" + }, + "packages/coding-agent/examples/extensions/gondolin": { + "name": "pi-extension-gondolin", + "version": "0.79.9", + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.9.9", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.79.9", "dependencies": { "ms": "2.1.3" }, @@ -6157,6 +5870,149 @@ "undici-types": "~7.16.0" } }, + "packages/coding-agent/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/coding-agent/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/coding-agent/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/coding-agent/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/coding-agent/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/coding-agent/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/coding-agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6164,17 +6020,133 @@ "dev": true, "license": "MIT" }, + "packages/coding-agent/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/coding-agent/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.75.4", + "version": "0.79.9", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "devDependencies": { "@xterm/headless": "5.5.0", - "@xterm/xterm": "5.5.0", "chalk": "5.6.2" }, "engines": { diff --git a/package.json b/package.json index 7a62003d..9bd3612d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", - "packages/coding-agent/examples/extensions/sandbox" + "packages/coding-agent/examples/extensions/sandbox", + "packages/coding-agent/examples/extensions/gondolin" ], "scripts": { "clean": "npm run clean --workspaces", @@ -20,18 +21,19 @@ "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", - "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", - "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", - "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", + "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", + "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", + "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:set": "npm version -ws", "prepublishOnly": "npm run clean && npm run build && npm run check", - "publish": "npm run prepublishOnly && npm publish -ws --access public", - "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "publish": "npm run prepublishOnly && node scripts/publish.mjs", + "publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run", "release:local": "node scripts/local-release.mjs", "shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs", "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", + "release:fix-links": "node scripts/release-notes.mjs fix-github-releases", "prepare": "husky" }, "devDependencies": { @@ -39,7 +41,7 @@ "@biomejs/biome": "2.3.5", "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "husky": "9.1.7", "jiti": "2.7.0", "shx": "0.4.0", @@ -55,5 +57,8 @@ "gaxios": { "rimraf": "6.1.2" } + }, + "dependencies": { + "@mistralai/mistralai": "2.2.6" } } diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4ec02d9a..ff48ad2c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,64 @@ ## [Unreleased] +## [0.79.9] - 2026-06-20 + +### Fixed + +- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). + +## [0.79.8] - 2026-06-19 + +### Added + +- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). + +## [0.79.7] - 2026-06-18 + +## [0.79.6] - 2026-06-16 + +## [0.79.5] - 2026-06-16 + +## [0.79.4] - 2026-06-15 + +## [0.79.3] - 2026-06-13 + +## [0.79.2] - 2026-06-12 + +### Fixed + +- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)). + +## [0.79.1] - 2026-06-09 + +## [0.79.0] - 2026-06-08 + +### Fixed + +- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). + +## [0.78.1] - 2026-06-04 + +## [0.78.0] - 2026-05-29 + +## [0.77.0] - 2026-05-28 + +### Breaking Changes + +- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`. + +### Added + +- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation. + +## [0.76.0] - 2026-05-27 + +### Fixed + +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). + +## [0.75.5] - 2026-05-23 + ## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/agent/README.md b/packages/agent/README.md index f0fedb81..ca4c1080 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -31,6 +31,24 @@ agent.subscribe((event) => { await agent.prompt("Hello!"); ``` +## Base Entry Point + +Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports: + +```typescript +import { Agent } from "@earendil-works/pi-agent-core/base"; +import { getModel } from "@earendil-works/pi-ai/base"; +import { register } from "@earendil-works/pi-ai/anthropic"; + +register(); + +const agent = new Agent({ + initialState: { model: getModel("anthropic", "claude-sonnet-4-6") }, +}); +``` + +The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility. + ## Core Concepts ### AgentMessage vs LLM Message diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index 65828889..64a8baec 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -258,13 +258,14 @@ Done: - Exported `QueueMode` from core types. - Added `AgentHarnessOptions.steeringMode` and `followUpMode`. - Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. +- Added `getTools()` and `getActiveTools()`. +- Added `tools_update` observability events, including active-tool-only updates. +- Active tool changes are persisted as branch-scoped `active_tools_change` entries. +- Duplicate tool names and duplicate active tool names reject. Remaining: -- Add `getTools()` semantics. -- Add `getActiveTools()` semantics. -- Decide and implement tool update observability events. -- Include active-tool-only updates in the runtime config observability plan. +- None. Notes: diff --git a/packages/agent/docs/durable-harness.md b/packages/agent/docs/durable-harness.md index 64e313df..ce5aa46d 100644 --- a/packages/agent/docs/durable-harness.md +++ b/packages/agent/docs/durable-harness.md @@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe - resource loaders - system-prompt callbacks/modifiers +Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations. + The practical target is a semi-durable harness: - session is the durable append-only state tree @@ -29,6 +31,7 @@ Existing session state already includes harness state: - model changes - thinking-level changes +- active-tool changes - leaf entries - labels - compactions and branch summaries @@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies: Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself. +## Runtime configuration and restore + +Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous. + +A future async builder/factory should own durable restore: + +```ts +const harness = await AgentHarness.builder() + .env(env) + .session(session) + .model(defaultModel) + .tools(runtimeTools) + .defaultActiveTools(["read", "edit"]) + .restore({ missingActiveTools: "fail" }); +``` + +`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction. + +For active tools: + +- `active_tools_change` entries are branch-scoped durable config. +- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied. +- Active tool names must be unique. +- Tool registry names must be unique. +- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later. +- Concrete tools are never restored from session; the host app must provide compatible tools. + ## What harness should persist Minimum useful durability entries: +- branch-scoped active tool names - queued steer/followUp/nextTurn messages - queue consumption tied to a turn - pending session writes accepted during active operations @@ -93,11 +124,11 @@ On startup: 3. Harness reduces session entries into: - current leaf - conversation branch - - harness config + - harness config, including active tool names - queues - pending writes - active operation/turn/tool state -4. Harness validates required runtime dependencies. +4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry. 5. Harness reconciles unfinished operation state. Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted. @@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished" ## Open questions -- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs? +- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs? - Should resolved system prompt text be snapshotted per turn for audit/debug? - Do we require strict dependency ID/version matching on resume? - How much provider request data should be journaled? diff --git a/packages/agent/package.json b/packages/agent/package.json index f5d823e7..75a73cb8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.75.4", + "version": "0.79.9", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -10,6 +10,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./base": { + "types": "./dist/base.d.ts", + "import": "./dist/base.js" + }, "./node": { "types": "./dist/node.d.ts", "import": "./dist/node.js" @@ -29,7 +33,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -45,7 +49,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/agent" }, "engines": { @@ -53,8 +57,8 @@ }, "devDependencies": { "@types/node": "24.12.4", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" } } diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 28f037f5..8a8f20f1 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -10,7 +10,7 @@ import { streamSimple, type ToolResultMessage, validateToolArguments, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import type { AgentContext, AgentEvent, @@ -631,6 +631,7 @@ async function executePreparedToolCall( emit: AgentEventSink, ): Promise { const updateEvents: Promise[] = []; + let acceptingUpdates = true; try { const result = await prepared.tool.execute( @@ -638,6 +639,7 @@ async function executePreparedToolCall( prepared.args as never, signal, (partialResult) => { + if (!acceptingUpdates) return; updateEvents.push( Promise.resolve( emit({ @@ -651,14 +653,18 @@ async function executePreparedToolCall( ); }, ); + acceptingUpdates = false; await Promise.all(updateEvents); return { result, isError: false }; } catch (error) { + acceptingUpdates = false; await Promise.all(updateEvents); return { result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, }; + } finally { + acceptingUpdates = false; } } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index db6684a8..ef26e213 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -7,7 +7,7 @@ import { type TextContent, type ThinkingBudgets, type Transport, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import type { AfterToolCallContext, diff --git a/packages/agent/src/base.ts b/packages/agent/src/base.ts new file mode 100644 index 00000000..ad1abafa --- /dev/null +++ b/packages/agent/src/base.ts @@ -0,0 +1,39 @@ +export * from "./agent.ts"; +export * from "./agent-loop.ts"; +export * from "./harness/agent-harness.ts"; +export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, + collectEntriesForBranchSummary, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization.ts"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, +} from "./harness/compaction/compaction.ts"; +export * from "./harness/messages.ts"; +export * from "./harness/prompt-templates.ts"; +export * from "./harness/session/jsonl-repo.ts"; +export * from "./harness/session/memory-repo.ts"; +export * from "./harness/session/repo-utils.ts"; +export * from "./harness/session/session.ts"; +export { uuidv7 } from "./harness/session/uuid.ts"; +export * from "./harness/skills.ts"; +export * from "./harness/system-prompt.ts"; +export * from "./harness/types.ts"; +export * from "./harness/utils/shell-output.ts"; +export * from "./harness/utils/truncate.ts"; +export * from "./proxy.ts"; +export * from "./types.ts"; diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 80535e8e..f097ee75 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -4,7 +4,7 @@ import { type Model, streamSimple, type UserMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import { runAgentLoop } from "../agent-loop.ts"; import type { AgentContext, @@ -86,6 +86,16 @@ function mergeHeaders(...headers: Array | undefined>): Re return hasHeaders ? merged : undefined; } +function findDuplicateNames(names: string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const name of names) { + if (seen.has(name)) duplicates.add(name); + seen.add(name); + } + return [...duplicates]; +} + function applyStreamOptionsPatch( base: AgentHarnessStreamOptions, patch?: AgentHarnessStreamOptionsPatch, @@ -194,12 +204,20 @@ export class AgentHarness< this.streamOptions = cloneStreamOptions(options.streamOptions); this.systemPrompt = options.systemPrompt; this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; + this.validateUniqueNames( + (options.tools ?? []).map((tool) => tool.name), + "Duplicate tool name(s)", + ); for (const tool of options.tools ?? []) { this.tools.set(tool.name, tool); } this.model = options.model; this.thinkingLevel = options.thinkingLevel ?? "off"; - this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); + this.activeToolNames = options.activeToolNames + ? [...options.activeToolNames] + : (options.tools ?? []).map((tool) => tool.name); + this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)"); + this.validateToolNames(this.activeToolNames); this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; } @@ -451,7 +469,14 @@ export class AgentHarness< }; } + private validateUniqueNames(names: string[], message: string): void { + const duplicates = findDuplicateNames(names); + if (duplicates.length > 0) + throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`); + } + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + this.validateUniqueNames(toolNames, "Duplicate active tool name(s)"); const missing = toolNames.filter((name) => !tools.has(name)); if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); } @@ -465,6 +490,8 @@ export class AgentHarness< await this.session.appendModelChange(write.provider, write.modelId); } else if (write.type === "thinking_level_change") { await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "active_tools_change") { + await this.session.appendActiveToolsChange(write.activeToolNames); } else if (write.type === "custom") { await this.session.appendCustomEntry(write.customType, write.data); } else if (write.type === "custom_message") { @@ -838,10 +865,6 @@ export class AgentHarness< return this.model; } - getThinkingLevel(): ThinkingLevel { - return this.thinkingLevel; - } - async setModel(model: Model): Promise { try { const previousModel = this.model; @@ -851,12 +874,16 @@ export class AgentHarness< this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); } this.model = model; - await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + await this.emitOwn({ type: "model_update", model, previousModel, source: "set" }); } catch (error) { throw normalizeHarnessError(error, "session"); } } + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + async setThinkingLevel(level: ThinkingLevel): Promise { try { const previousLevel = this.thinkingLevel; @@ -866,16 +893,70 @@ export class AgentHarness< this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); } this.thinkingLevel = level; - await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + await this.emitOwn({ type: "thinking_level_update", level, previousLevel }); } catch (error) { throw normalizeHarnessError(error, "session"); } } + getTools(): TTool[] { + return [...this.tools.values()]; + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { + try { + this.validateUniqueNames( + tools.map((tool) => tool.name), + "Duplicate tool name(s)", + ); + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(nextActiveToolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] }); + } + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getActiveTools(): TTool[] { + return this.activeToolNames.map((name) => this.tools.get(name)!); + } + async setActiveTools(toolNames: string[]): Promise { try { this.validateToolNames(toolNames); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(toolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] }); + } this.activeToolNames = [...toolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); } catch (error) { throw normalizeHarnessError(error, "invalid_argument"); } @@ -921,18 +1002,6 @@ export class AgentHarness< this.streamOptions = cloneStreamOptions(streamOptions); } - async setTools(tools: TTool[], activeToolNames?: string[]): Promise { - try { - const nextTools = new Map(tools.map((tool) => [tool.name, tool])); - const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; - this.validateToolNames(nextActiveToolNames, nextTools); - this.tools = nextTools; - this.activeToolNames = [...nextActiveToolNames]; - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } - } - async abort(): Promise { const clearedSteer = [...this.steerQueue]; const clearedFollowUp = [...this.followUpQueue]; diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index 6ca4a430..60ffe771 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,5 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { completeSimple } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; import { convertToLlm, @@ -112,6 +111,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); case "thinking_level_change": case "model_change": + case "active_tools_change": case "custom": case "label": case "session_info": diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index aa7b32c6..d619b34a 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,5 +1,11 @@ -import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai"; -import { completeSimple } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + completeSimple, + type ImageContent, + type Model, + type TextContent, + type Usage, +} from "@earendil-works/pi-ai/base"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import { convertToLlm, @@ -198,22 +204,33 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett return contextTokens > contextWindow - settings.reserveTokens; } +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** Estimate token count for one message using a conservative character heuristic. */ export function estimateTokens(message: AgentMessage): number { let chars = 0; switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -231,18 +248,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { @@ -281,6 +287,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end } case "thinking_level_change": case "model_change": + case "active_tools_change": case "compaction": case "branch_summary": case "custom": @@ -375,7 +382,7 @@ export function findCutPoint( }; } -export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 07535b79..388dd84a 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -1,4 +1,4 @@ -import type { Message } from "@earendil-works/pi-ai"; +import type { Message } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; /** File paths touched by a session branch or compaction range. */ diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index e56e7aeb..3d929c82 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -144,12 +144,25 @@ async function findBashOnPath(): Promise { return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; } -async function getShellConfig( - customShellPath?: string, -): Promise> { +interface ShellConfig { + shell: string; + args: string[]; + commandTransport?: "argv" | "stdin"; +} + +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + +async function getShellConfig(customShellPath?: string): Promise> { if (customShellPath) { if (await pathExists(customShellPath)) { - return ok({ shell: customShellPath, args: ["-c"] }); + return ok(getBashShellConfig(customShellPath)); } return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`)); } @@ -161,22 +174,22 @@ async function getShellConfig( if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); for (const candidate of candidates) { if (await pathExists(candidate)) { - return ok({ shell: candidate, args: ["-c"] }); + return ok(getBashShellConfig(candidate)); } } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return ok({ shell: bashOnPath, args: ["-c"] }); + return ok(getBashShellConfig(bashOnPath)); } return err(new ExecutionError("shell_unavailable", "No bash shell found")); } if (await pathExists("/bin/bash")) { - return ok({ shell: "/bin/bash", args: ["-c"] }); + return ok(getBashShellConfig("/bin/bash")); } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return ok({ shell: bashOnPath, args: ["-c"] }); + return ok(getBashShellConfig(bashOnPath)); } return ok({ shell: "sh", args: ["-c"] }); } @@ -274,13 +287,22 @@ export class NodeExecutionEnv implements ExecutionEnv { }; try { - child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { - cwd, - detached: process.platform !== "win32", - env: getShellEnv(this.shellEnv, options?.env), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); + const commandFromStdin = shellConfig.value.commandTransport === "stdin"; + child = spawn( + shellConfig.value.shell, + commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command], + { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } } catch (error) { const cause = toError(error); settle(err(new ExecutionError("spawn_error", cause.message, cause))); diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 36ce96a1..8368dd34 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -1,4 +1,4 @@ -import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../types.ts"; export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 4baf50ec..3fd76c6b 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,7 +1,8 @@ -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { + ActiveToolsChangeEntry, BranchSummaryEntry, CompactionEntry, CustomEntry, @@ -21,6 +22,7 @@ import { SessionError } from "../types.ts"; export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; + let activeToolNames: string[] | null = null; let compaction: CompactionEntry | null = null; for (const entry of pathEntries) { @@ -30,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon model = { provider: entry.provider, modelId: entry.modelId }; } else if (entry.type === "message" && entry.message.role === "assistant") { model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "active_tools_change") { + activeToolNames = [...entry.activeToolNames]; } else if (entry.type === "compaction") { compaction = entry; } @@ -72,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon } } - return { messages, thinkingLevel, model }; + return { messages, thinkingLevel, model, activeToolNames }; } export class Session { @@ -156,6 +160,16 @@ export class Session { } satisfies ModelChangeEntry); } + async appendActiveToolsChange(activeToolNames: string[]): Promise { + return this.appendTypedEntry({ + type: "active_tools_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + activeToolNames: [...activeToolNames], + } satisfies ActiveToolsChangeEntry); + } + async appendCompaction( summary: string, firstKeptEntryId: string, diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 2d9096c4..8f281570 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,5 +1,5 @@ -import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai"; -import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts"; +import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts"; import type { Session } from "./session/session.ts"; /** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ @@ -354,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase { modelId: string; } +export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { + type: "active_tools_change"; + activeToolNames: string[]; +} + export interface CompactionEntry extends SessionTreeEntryBase { type: "compaction"; summary: string; @@ -405,6 +410,7 @@ export type SessionTreeEntry = | MessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry + | ActiveToolsChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry @@ -417,6 +423,7 @@ export interface SessionContext { messages: AgentMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; + activeToolNames: string[] | null; } export interface SessionMetadata { @@ -593,19 +600,28 @@ export interface SessionTreeEvent { fromHook?: boolean; } -export interface ModelSelectEvent { - type: "model_select"; +export interface ModelUpdateEvent { + type: "model_update"; model: Model; previousModel: Model | undefined; source: "set" | "restore"; } -export interface ThinkingLevelSelectEvent { - type: "thinking_level_select"; +export interface ThinkingLevelUpdateEvent { + type: "thinking_level_update"; level: ThinkingLevel; previousLevel: ThinkingLevel; } +export interface ToolsUpdateEvent { + type: "tools_update"; + toolNames: string[]; + previousToolNames: string[]; + activeToolNames: string[]; + previousActiveToolNames: string[]; + source: "set" | "restore"; +} + export interface ResourcesUpdateEvent< TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, @@ -634,9 +650,10 @@ export type AgentHarnessOwnEvent< | SessionCompactEvent | SessionBeforeTreeEvent | SessionTreeEvent - | ModelSelectEvent - | ThinkingLevelSelectEvent - | ResourcesUpdateEvent; + | ModelUpdateEvent + | ThinkingLevelUpdateEvent + | ResourcesUpdateEvent + | ToolsUpdateEvent; export type AgentHarnessEvent = | AgentEvent @@ -696,9 +713,10 @@ export type AgentHarnessEventResultMap = { session_compact: undefined; session_before_tree: SessionBeforeTreeResult | undefined; session_tree: undefined; - model_select: undefined; - thinking_level_select: undefined; + model_update: undefined; + thinking_level_update: undefined; resources_update: undefined; + tools_update: undefined; queue_update: undefined; save_point: undefined; abort: undefined; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fd3c2b9f..08b7b9cb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,44 +1,5 @@ -// Core Agent -export * from "./agent.ts"; -// Loop functions -export * from "./agent-loop.ts"; -export * from "./harness/agent-harness.ts"; -export { - type BranchPreparation, - type BranchSummaryDetails, - type CollectEntriesResult, - collectEntriesForBranchSummary, - generateBranchSummary, - prepareBranchEntries, -} from "./harness/compaction/branch-summarization.ts"; -export { - calculateContextTokens, - compact, - DEFAULT_COMPACTION_SETTINGS, - estimateContextTokens, - estimateTokens, - findCutPoint, - findTurnStartIndex, - generateSummary, - getLastAssistantUsage, - prepareCompaction, - serializeConversation, - shouldCompact, -} from "./harness/compaction/compaction.ts"; -export * from "./harness/messages.ts"; -export * from "./harness/prompt-templates.ts"; -export * from "./harness/session/jsonl-repo.ts"; -export * from "./harness/session/memory-repo.ts"; -export * from "./harness/session/repo-utils.ts"; -export * from "./harness/session/session.ts"; -export { uuidv7 } from "./harness/session/uuid.ts"; -export * from "./harness/skills.ts"; -export * from "./harness/system-prompt.ts"; -// Harness -export * from "./harness/types.ts"; -export * from "./harness/utils/shell-output.ts"; -export * from "./harness/utils/truncate.ts"; -// Proxy utilities -export * from "./proxy.ts"; -// Types -export * from "./types.ts"; +// Import the default pi-ai entrypoint so that all built-in providers register +// automatically. Unlike "@earendil-works/pi-ai/base" which does not. +import "@earendil-works/pi-ai"; + +export * from "./base.ts"; diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5f0925c9..8e5cb2c2 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -14,7 +14,7 @@ import { type SimpleStreamOptions, type StopReason, type ToolCall, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; // Create stream class matching ProxyMessageEventStream class ProxyMessageEventStream extends EventStream { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index f24d2496..541d8d63 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -9,7 +9,7 @@ import type { TextContent, Tool, ToolResultMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import type { Static, TSchema } from "typebox"; /** @@ -354,7 +354,12 @@ export interface AgentToolResult { terminate?: boolean; } -/** Callback used by tools to stream partial execution updates. */ +/** + * Callback used by tools to stream partial execution updates. + * + * The callback is scoped to the current `execute()` invocation. Calls made after + * the tool promise settles are ignored. + */ export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; /** Tool definition used by the agent runtime. */ diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 82cc58de..4cc51f74 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,6 +1,7 @@ import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { Agent } from "../src/index.ts"; +import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { @@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage { }; } +type ToolCallContent = Extract; + +function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "openai", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + function createDeferred(): { promise: Promise; resolve: () => void; @@ -242,6 +265,147 @@ describe("Agent", () => { expect(receivedSignal?.aborted).toBe(true); }); + it("should ignore tool updates after the tool execution settles", async () => { + const toolSchema = Type.Object({}); + let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined; + const events: AgentEvent[] = []; + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (error: unknown) => { + unhandledRejections.push(error); + }; + const tool: AgentTool = { + name: "delayed_tool", + label: "Delayed Tool", + description: "Captures progress callbacks", + parameters: toolSchema, + async execute(_toolCallId, _params, _signal, onUpdate) { + delayedUpdate = onUpdate; + onUpdate?.({ + content: [{ type: "text", text: "running" }], + details: { status: "running" }, + }); + return { + content: [{ type: "text", text: "ok" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const agent = new Agent({ + initialState: { tools: [tool] }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "toolUse", + message: createAssistantToolUseMessage([ + { type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} }, + ]), + }); + }); + return stream; + }, + }); + agent.subscribe((event) => { + events.push(event); + }); + + process.on("unhandledRejection", onUnhandledRejection); + try { + await agent.prompt("run tool"); + const eventCountAfterPrompt = events.length; + + delayedUpdate?.({ + content: [{ type: "text", text: "late" }], + details: { status: "late" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1); + expect(events).toHaveLength(eventCountAfterPrompt); + expect(unhandledRejections).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("should ignore a settled parallel tool update while another tool is still running", async () => { + const toolSchema = Type.Object({}); + const slowStarted = createDeferred(); + const settledToolEnded = createDeferred(); + const releaseSlow = createDeferred(); + let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined; + const events: AgentEvent[] = []; + const settledTool: AgentTool = { + name: "settled_tool", + label: "Settled Tool", + description: "Captures progress callbacks", + parameters: toolSchema, + async execute(_toolCallId, _params, _signal, onUpdate) { + settledToolUpdate = onUpdate; + return { + content: [{ type: "text", text: "done" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const slowTool: AgentTool = { + name: "slow_tool", + label: "Slow Tool", + description: "Keeps the agent run active", + parameters: toolSchema, + async execute() { + slowStarted.resolve(); + await releaseSlow.promise; + return { + content: [{ type: "text", text: "done" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const agent = new Agent({ + initialState: { tools: [settledTool, slowTool] }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "toolUse", + message: createAssistantToolUseMessage([ + { type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} }, + { type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} }, + ]), + }); + }); + return stream; + }, + }); + agent.subscribe((event) => { + events.push(event); + if (event.type === "tool_execution_end" && event.toolCallId === "call-1") { + settledToolEnded.resolve(); + } + }); + + const promptPromise = agent.prompt("run tools"); + await Promise.all([slowStarted.promise, settledToolEnded.promise]); + const eventCountBeforeLateUpdate = events.length; + + settledToolUpdate?.({ + content: [{ type: "text", text: "late" }], + details: { status: "late" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toHaveLength(eventCountBeforeLateUpdate); + + releaseSlow.resolve(); + await promptPromise; + expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0); + }); + it("should update state with mutators", () => { const agent = new Agent(); diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index 8a395532..1d24eb4c 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -17,10 +17,6 @@ interface AppPromptTemplate extends PromptTemplate { source: "project" | "user"; } -interface AppTool extends AgentTool { - source: "builtin" | "extension"; -} - const registrations: Array<{ unregister(): void }> = []; function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] { @@ -458,11 +454,111 @@ describe("AgentHarness", () => { }); }); + it("preserves app tool types for getters and update events", async () => { + const session = new Session(new InMemorySessionStorage()); + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + const model = getModel("anthropic", "claude-sonnet-4-5"); + type AppTool = AgentTool & { source: "builtin" | "extension" }; + const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" }; + const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" }; + const harness = new AgentHarness({ + env, + session, + model, + tools: [inspectTool, searchTool], + activeToolNames: ["inspect"], + }); + const updates: Array<{ + toolNames: string[]; + previousToolNames: string[]; + activeToolNames: string[]; + previousActiveToolNames: string[]; + source: "set" | "restore"; + }> = []; + harness.subscribe((event) => { + if (event.type === "tools_update") { + updates.push({ + toolNames: event.toolNames, + previousToolNames: event.previousToolNames, + activeToolNames: event.activeToolNames, + previousActiveToolNames: event.previousActiveToolNames, + source: event.source, + }); + expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames); + } + }); + + const tools = harness.getTools(); + const activeTools = harness.getActiveTools(); + tools.pop(); + activeTools.pop(); + expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]); + expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]); + + await harness.setActiveTools(["search"]); + await harness.setTools([searchTool], ["search"]); + await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({ + code: "invalid_argument", + }); + + expect(updates).toEqual([ + { + toolNames: ["inspect", "search"], + previousToolNames: ["inspect", "search"], + activeToolNames: ["search"], + previousActiveToolNames: ["inspect"], + source: "set", + }, + { + toolNames: ["search"], + previousToolNames: ["inspect", "search"], + activeToolNames: ["search"], + previousActiveToolNames: ["search"], + source: "set", + }, + ]); + expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]); + expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]); + expect((await session.buildContext()).activeToolNames).toEqual(["search"]); + }); + + it("validates constructor tool names", () => { + const session = new Session(new InMemorySessionStorage()); + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + const model = getModel("anthropic", "claude-sonnet-4-5"); + expect( + () => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }), + ).toThrow(/Unknown tool/); + expect( + () => + new AgentHarness({ + env, + session, + model, + tools: [calculateTool, calculateTool], + activeToolNames: [calculateTool.name], + }), + ).toThrow(/Duplicate tool/); + expect( + () => + new AgentHarness({ + env, + session, + model, + tools: [calculateTool], + activeToolNames: [calculateTool.name, calculateTool.name], + }), + ).toThrow(/Duplicate active tool/); + }); + it("preserves app resource types for getters and update events", async () => { const session = new Session(new InMemorySessionStorage()); const env = new NodeExecutionEnv({ cwd: process.cwd() }); const model = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ env, session, model }); + const harness = new AgentHarness({ env, session, model }); const skill: AppSkill = { name: "inspect", description: "Inspect things", diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 758d5f59..d2d33a6f 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -1,5 +1,5 @@ import { access, chmod, realpath, symlink } from "node:fs/promises"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { FileError, getOrThrow } from "../../src/harness/types.ts"; @@ -201,6 +201,39 @@ describe("NodeExecutionEnv", () => { expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); + it("uses stdin command transport for legacy WSL bash paths", async () => { + if (process.platform === "win32") return; + const root = createTempDir(); + const shellPath = "C:\\Windows\\System32\\bash.exe"; + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n')); + await chmod(join(root, shellPath), 0o755); + + const originalCwd = process.cwd(); + const originalPath = process.env.PATH; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + try { + process.chdir(root); + process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`; + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath }); + const nameExpansion = "$" + "{name}"; + const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`)); + + expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 }); + } finally { + process.chdir(originalCwd); + process.env.PATH = originalPath; + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + it("streams stdout and stderr chunks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); diff --git a/packages/agent/tsconfig.build.json b/packages/agent/tsconfig.build.json index 695dd9ad..979dbc89 100644 --- a/packages/agent/tsconfig.build.json +++ b/packages/agent/tsconfig.build.json @@ -2,6 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", + "paths": { + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"] + }, "rootDir": "./src" }, "include": ["src/**/*.ts"], diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index bcc497fa..297fbd50 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname, + "@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname, + }, + }, test: { globals: true, environment: "node", diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 9421e5a9..37acc34d 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname, + "@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname, + }, + }, test: { globals: true, environment: "node", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 841f3fed..586260cf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,174 @@ ## [Unreleased] +## [0.79.9] - 2026-06-20 + +### Added + +- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)). + ### Fixed +- Fixed Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)). +- Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)). +- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)). + +## [0.79.8] - 2026-06-19 + +### Added + +- Added `@earendil-works/pi-ai/base` and direct provider registration exports for bundlers that want selective provider transports without root built-in registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). +- Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). +- Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). + +## [0.79.7] - 2026-06-18 + +### Added + +- Added GLM-5.2 model to the OpenCode Go subscription model catalog ([#5860](https://github.com/earendil-works/pi/issues/5860)). + +## [0.79.6] - 2026-06-16 + +### Fixed + +- Fixed OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter. + +## [0.79.5] - 2026-06-16 + +### Added + +- Added provider-scoped `StreamOptions.env` overrides for provider configuration, including Cloudflare endpoint placeholders, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy environment lookups ([#5728](https://github.com/earendil-works/pi/issues/5728)). + +### Fixed + +- Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)). +- Fixed OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)). +- Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). +- Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). +- Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). + +## [0.79.4] - 2026-06-15 + +### Fixed + +- Fixed Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)). +- Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)). +- Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)). + +## [0.79.3] - 2026-06-13 + +### Fixed + +- Restored OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to the observed 272k-token Codex backend limit, avoiding a billing hazard from sending prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)). + +## [0.79.2] - 2026-06-12 + +### Added + +- Added AWS data retention documentation links to Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). + +### Fixed + +- Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)). +- Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). +- Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)). +- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). + +## [0.79.1] - 2026-06-09 + +### Added + +- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support. + +### Fixed + +- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). +- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). +- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). + +## [0.79.0] - 2026-06-08 + +### Fixed + +- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). + +## [0.78.1] - 2026-06-04 + +### Added + +- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models. +- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)). +- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use. + +### Fixed + +- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). +- Fixed Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). +- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)). +- Fixed OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)). +- Restored the NVIDIA Qwen 3.5 122B NIM model. + +## [0.78.0] - 2026-05-29 + +### Breaking Changes + +- Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth. + +### Added + +- Added custom Amazon Bedrock request header support via `StreamOptions.headers`, excluding reserved AWS signing headers ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)). + +### Fixed + +- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)). +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)). +- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events. +- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters. + +## [0.77.0] - 2026-05-28 + +### Added + +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). +- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it. + +### Fixed + +- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)). +- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). +- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). +- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. +- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). +- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)). + +## [0.76.0] - 2026-05-27 + +### Fixed + +- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). +- Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). +- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). +- Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed provider retry controls so OpenAI Codex Responses honors `maxRetries`, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)). + +## [0.75.5] - 2026-05-23 + +### Breaking Changes + +- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). + +### Fixed + +- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). +- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). +- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). ## [0.75.4] - 2026-05-20 @@ -13,6 +179,10 @@ - Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks. - Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources. +### Added + +- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration. + ### Fixed - Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)). diff --git a/packages/ai/README.md b/packages/ai/README.md index 1790a64c..bec437aa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -8,6 +8,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - [Supported Providers](#supported-providers) - [Installation](#installation) +- [Base Entry Point](#base-entry-point) - [Quick Start](#quick-start) - [Tools](#tools) - [Defining Tools](#defining-tools) @@ -38,6 +39,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - [Browser Usage](#browser-usage) - [Browser Compatibility Notes](#browser-compatibility-notes) - [Environment Variables](#environment-variables-nodejs-only) + - [Provider-Scoped Environment Overrides](#provider-scoped-environment-overrides) - [Checking Environment Variables](#checking-environment-variables) - [OAuth Providers](#oauth-providers) - [Vertex AI](#vertex-ai) @@ -51,9 +53,11 @@ Unified LLM API with automatic model discovery, provider configuration, token an ## Supported Providers - **OpenAI** +- **Ant Ling** - **Azure OpenAI (Responses)** - **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below) - **DeepSeek** +- **NVIDIA NIM** - **Anthropic** - **Google** - **Vertex AI** (Gemini via Vertex AI) @@ -65,6 +69,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **xAI** - **OpenRouter** - **Vercel AI Gateway** +- **ZAI** (with separate Coding Plan China provider) - **MiniMax** - **Together AI** - **GitHub Copilot** (requires OAuth, see below) @@ -84,6 +89,30 @@ npm install @earendil-works/pi-ai TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`. +## Base Entry Point + +Use `@earendil-works/pi-ai/base` when a bundler should include only explicitly selected transport implementations. The base entry point exposes model discovery, registries, generic dispatch, environment-key helpers, and provider-neutral utilities without registering built-in transports during module initialization. Generic dispatch still resolves configured environment keys when called. + +Import each selected transport directly and call its `register()` function: + +```typescript +import { getModel, streamSimple } from '@earendil-works/pi-ai/base'; +import { register as registerAnthropic } from '@earendil-works/pi-ai/anthropic'; + +registerAnthropic(); + +const model = getModel('anthropic', 'claude-sonnet-4-6'); +const response = await streamSimple(model, { + messages: [{ role: 'user', content: 'Hello!' }] +}, { + apiKey: process.env.ANTHROPIC_API_KEY +}).result(); +``` + +Direct transport imports bundle their implementation and SDK code. Register only the transports used by the application. For example, OpenRouter, Groq, xAI, and DeepSeek chat models share the `@earendil-works/pi-ai/openai-completions` transport. + +Use `@earendil-works/pi-ai` for the batteries-included behavior. The root entry point remains backward-compatible: it registers lazy wrappers for all built-in transports and resolves environment API keys automatically during dispatch. + ## Quick Start ```typescript @@ -434,7 +463,7 @@ Do not use `stream()` or `complete()` for image generation. Image generation is ### Basic Image Generation ```typescript -import { getImageModel, generateImages } from '@mariozechner/pi-ai'; +import { getImageModel, generateImages } from '@earendil-works/pi-ai'; const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image'); @@ -801,7 +830,7 @@ A **provider** offers models through a specific API. For example: - **Google** models use the `google-generative-ai` API - **OpenAI** models use the `openai-responses` API - **Mistral** models use the `mistral-conversations` API -- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible) +- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible) ### Querying Providers and Models @@ -923,7 +952,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -938,14 +967,17 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) + chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) } interface OpenAIResponsesCompat { - // Reserved for future use + supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) + sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true) + supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true) } ``` @@ -1091,6 +1123,7 @@ const response = await complete(model, { - OAuth login flows are not supported in browser environments. Use the `@earendil-works/pi-ai/oauth` entry point in Node.js. - In browser builds, Bedrock can still appear in model lists. Calls to Bedrock models fail at runtime. - Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app. +- Use `@earendil-works/pi-ai/base` plus explicit direct transport registration when a browser bundle should exclude unused provider SDK implementations. ### Environment Variables (Node.js only) @@ -1099,9 +1132,11 @@ In Node.js environments, you can set environment variables to avoid passing API | Provider | Environment Variable(s) | |----------|------------------------| | OpenAI | `OPENAI_API_KEY` | +| Ant Ling | `ANT_LING_API_KEY` | | Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | | DeepSeek | `DEEPSEEK_API_KEY` | +| NVIDIA NIM | `NVIDIA_API_KEY` | | Google | `GEMINI_API_KEY` | | Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC | | Mistral | `MISTRAL_API_KEY` | @@ -1115,6 +1150,7 @@ In Node.js environments, you can set environment variables to avoid passing API | OpenRouter | `OPENROUTER_API_KEY` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | | zAI | `ZAI_API_KEY` | +| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | | MiniMax | `MINIMAX_API_KEY` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | @@ -1137,6 +1173,24 @@ const response = await complete(model, context, { }); ``` +### Provider-Scoped Environment Overrides + +Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for API key discovery and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. + +```typescript +const model = getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6'); + +const response = await complete(model, context, { + env: { + CLOUDFLARE_API_KEY: '...', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_GATEWAY_ID: 'gateway-id' + } +}); +``` + +Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call. + ### Checking Environment Variables ```typescript @@ -1306,6 +1360,7 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports: - `stream()` function returning `AssistantMessageEventStream` - `streamSimple()` for `SimpleStreamOptions` mapping +- `register()` for explicit direct transport registration from `@earendil-works/pi-ai/base` - Provider-specific options interface - Message conversion functions to transform `Context` to provider format - Tool conversion if the provider supports tools @@ -1316,7 +1371,8 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports: - Register the API with `registerApiProvider()` - Add a package subpath export in `package.json` for the provider module (`./dist/providers/.js`) - Add lazy loader wrappers in `src/providers/register-builtins.ts`, do not statically import provider implementation modules there -- Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai` +- Add any root-level `export type` re-exports in `src/index.ts` and `src/base.ts` that should remain available from `@earendil-works/pi-ai` and `@earendil-works/pi-ai/base` +- Keep `src/base.ts` free of built-in registration imports - Add credential detection in `env-api-keys.ts` for the new provider - Ensure `streamSimple` handles auth lookup via `getEnvApiKey()` or provider-specific auth diff --git a/packages/ai/package.json b/packages/ai/package.json index a10a23df..77272079 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.75.4", + "version": "0.79.9", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -10,6 +10,14 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./base": { + "types": "./dist/base.d.ts", + "import": "./dist/base.js" + }, + "./amazon-bedrock": { + "types": "./dist/providers/amazon-bedrock.d.ts", + "import": "./dist/providers/amazon-bedrock.js" + }, "./anthropic": { "types": "./dist/providers/anthropic.d.ts", "import": "./dist/providers/anthropic.js" @@ -42,6 +50,10 @@ "types": "./dist/providers/openai-responses.d.ts", "import": "./dist/providers/openai-responses.js" }, + "./openrouter-images": { + "types": "./dist/providers/images/openrouter.d.ts", + "import": "./dist/providers/images/openrouter.js" + }, "./oauth": { "types": "./dist/oauth.d.ts", "import": "./dist/oauth.js" @@ -70,7 +82,9 @@ "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", @@ -91,7 +105,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/ai" }, "engines": { @@ -100,6 +114,6 @@ "devDependencies": { "@types/node": "24.12.4", "canvas": "3.2.3", - "vitest": "3.2.4" + "vitest": "4.1.9" } } diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 0c025c68..3e85db82 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -32,12 +32,17 @@ interface ModelsDevModel { }; modalities?: { input?: string[]; + output?: string[]; }; provider?: { npm?: string; }; } +interface NvidiaNimModelListItem { + id: string; +} + interface AiGatewayModel { id: string; name?: string; @@ -63,6 +68,8 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; +const MOONSHOT_CN_MIRRORED_MODEL_IDS = new Set(["kimi-k2.7-code", "kimi-k2.7-code-highspeed"]); + const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -87,7 +94,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { }; const TOGETHER_REASONING_ONLY_MODELS = new Set([ "deepseek-ai/DeepSeek-R1", - "MiniMaxAI/MiniMax-M2.5", "MiniMaxAI/MiniMax-M2.7", ]); const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); @@ -117,7 +123,47 @@ const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = { const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; +const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; +const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; +const NVIDIA_HEADERS = { + "NVCF-POLL-SECONDS": "3600", +} as const; +const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, +}; +const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ + "abacusai/dracarys-llama-3.1-70b-instruct", + "bytedance/seed-oss-36b-instruct", + "deepseek-ai/deepseek-v4-flash", + "deepseek-ai/deepseek-v4-pro", + "google/gemma-2-2b-it", + "google/gemma-3n-e2b-it", + "google/gemma-3n-e4b-it", + "google/gemma-4-31b-it", + "meta/llama-3.2-1b-instruct", + "meta/llama-4-maverick-17b-128e-instruct", + "microsoft/phi-4-mini-instruct", + "minimaxai/minimax-m2.7", + "mistralai/mistral-nemotron", + "nvidia/nemotron-mini-4b-instruct", + "qwen/qwen3-next-80b-a3b-instruct", + "qwen/qwen3.5-397b-a17b", + "sarvamai/sarvam-m", + "upstage/solar-10.7b-instruct", +]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); +const ZAI_GLM52_THINKING_LEVEL_MAP = { + minimal: null, + low: "high", + medium: "high", + high: "high", + xhigh: "max", +} as const; const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ "github-copilot:claude-haiku-4.5", "github-copilot:claude-sonnet-4", @@ -132,6 +178,15 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { xhigh: "max", } as const; +const ANT_LING_RING_THINKING_LEVEL_MAP = { + off: null, + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: "xhigh", +} as const; + const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.1", "gpt-5.2", @@ -142,6 +197,23 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.5", ]); +const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([ + "opencode:deepseek-v4-flash", + "opencode:deepseek-v4-pro", + "opencode:kimi-k2.5", + "opencode:kimi-k2.6", + "opencode:minimax-m2.7", + "opencode-go:kimi-k2.6", +]); + +// Checked manually against the authenticated GitHub Copilot /models endpoint on 2026-06-15. +// Keep this to narrow corrections over models.dev metadata instead of snapshotting Copilot's catalog. +const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES = { + "claude-opus-4.7": { minimal: "low" }, + "claude-opus-4.8": { minimal: "low" }, + "claude-sonnet-4.6": { minimal: "low", xhigh: "max" }, +} satisfies Record["thinkingLevelMap"]>>; + function mergeThinkingLevelMap(model: Model, map: NonNullable["thinkingLevelMap"]>): void { model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; } @@ -178,12 +250,36 @@ function isGoogleThinkingApi(model: Model): boolean { return model.api === "google-generative-ai" || model.api === "google-vertex"; } +function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { + return ( + modelId.includes("opus-4-6") || + modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || + modelId.includes("opus-4-8") || + modelId.includes("opus-4.8") || + modelId.includes("sonnet-4-6") || + modelId.includes("sonnet-4.6") || + modelId.includes("fable-5") + ); +} + +function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean { + const id = modelId.toLowerCase(); + return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8"); +} + +function mergeAnthropicMessagesCompat(model: Model, compat: AnthropicMessagesCompat): void { + model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat }; +} + function isGemini3ProModel(modelId: string): boolean { return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); } function isGemini3FlashModel(modelId: string): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(modelId.toLowerCase()); + const id = modelId.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function isGemma4Model(modelId: string): boolean { @@ -210,14 +306,42 @@ function applyThinkingLevelMetadata(model: Model): void { if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.provider === "openai" && model.id === "gpt-5.5") { + mergeThinkingLevelMap(model, { minimal: null }); + } + if (model.id.endsWith("gpt-5.5-pro")) { + mergeThinkingLevelMap(model, { off: null, minimal: null, low: null }); + } if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) { mergeThinkingLevelMap(model, { xhigh: "max" }); } - if (model.id.includes("opus-4-7") || model.id.includes("opus-4.7")) { + if ( + model.id.includes("opus-4-7") || + model.id.includes("opus-4.7") || + model.id.includes("opus-4-8") || + model.id.includes("opus-4.8") + ) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if ( + (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") && + model.id.includes("fable-5") + ) { + mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh" }); + } + if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { + mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); + } + if (model.api === "anthropic-messages" && isAnthropicTemperatureUnsupportedModel(model.id)) { + mergeAnthropicMessagesCompat(model, { supportsTemperature: false }); + } if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { - mergeThinkingLevelMap(model, DEEPSEEK_V4_THINKING_LEVEL_MAP); + mergeThinkingLevelMap( + model, + model.provider === "openrouter" + ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh" } + : DEEPSEEK_V4_THINKING_LEVEL_MAP, + ); } if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) { mergeThinkingLevelMap(model, { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" }); @@ -234,6 +358,15 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { minimal: "low" }); } + if ( + (model.provider === "moonshotai" || model.provider === "moonshotai-cn") && + (model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed") + ) { + // Kimi K2.7 Code is always-thinking. Official docs say + // `thinking: { type: "disabled" }` is rejected, and callers can omit + // the thinking parameter to use the enabled default. + mergeThinkingLevelMap(model, { off: null }); + } if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) { // Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling. // Mark "off" unsupported so the openai-completions provider omits the reasoning param @@ -241,12 +374,41 @@ function applyThinkingLevelMetadata(model: Model): void { // Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary. mergeThinkingLevelMap(model, { off: null }); } + if (model.provider === "openrouter" && model.id === "z-ai/glm-5.2") { + mergeThinkingLevelMap(model, { xhigh: "xhigh" }); + } + if (model.provider === "fireworks" && model.id === "accounts/fireworks/models/glm-5p2") { + mergeThinkingLevelMap(model, { off: "none", minimal: null, low: "high", medium: "high", xhigh: "max" }); + } + if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { + // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. + mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); + } + if (model.provider === "opencode" && model.id === "grok-build-0.1") { + // OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort. + mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null }); + } + if (model.provider === "ant-ling" && model.reasoning) { + // Ring reasons by default. Only high/xhigh have documented explicit effort controls. + mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP); + } + if (model.provider === "github-copilot") { + const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id]; + if (override) { + mergeThinkingLevelMap(model, override); + } + } } function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { - return EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`) - ? { supportsEagerToolInputStreaming: false } - : undefined; + const compat: AnthropicMessagesCompat = {}; + if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { + compat.supportsEagerToolInputStreaming = false; + } + if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { + compat.allowEmptySignature = true; + } + return Object.keys(compat).length > 0 ? compat : undefined; } function getBedrockBaseUrl(modelId: string): string { @@ -255,6 +417,34 @@ function getBedrockBaseUrl(modelId: string): string { : "https://bedrock-runtime.us-east-1.amazonaws.com"; } +function normalizeNvidiaModelId(modelId: string): string { + return modelId.toLowerCase().replaceAll("_", "."); +} + +function roundCost(value: number): number { + return Number(value.toFixed(6)); +} + +async function fetchNvidiaNimModelIds(): Promise> { + try { + console.log("Fetching models from NVIDIA NIM API..."); + const response = await fetch(`${NVIDIA_BASE_URL}/models`); + const data = (await response.json()) as { data?: NvidiaNimModelListItem[] }; + const modelIds = new Map(); + + for (const model of data.data ?? []) { + modelIds.set(model.id, model.id); + modelIds.set(normalizeNvidiaModelId(model.id), model.id); + } + + console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`); + return modelIds; + } catch (error) { + console.error("Failed to fetch NVIDIA NIM models:", error); + return new Map(); + } +} + async function fetchOpenRouterModels(): Promise[]> { try { console.log("Fetching models from OpenRouter API..."); @@ -280,10 +470,10 @@ async function fetchOpenRouterModels(): Promise[]> { } // Convert pricing from $/token to $/million tokens - const inputCost = parseFloat(model.pricing?.prompt || "0") * 1_000_000; - const outputCost = parseFloat(model.pricing?.completion || "0") * 1_000_000; - const cacheReadCost = parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000; - const cacheWriteCost = parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000; + const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000); + const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000); + const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000); + const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000); const normalizedModel: Model = { id: modelKey, @@ -339,10 +529,10 @@ async function fetchAiGatewayModels(): Promise[]> { input.push("image"); } - const inputCost = toNumber(model.pricing?.input) * 1_000_000; - const outputCost = toNumber(model.pricing?.output) * 1_000_000; - const cacheReadCost = toNumber(model.pricing?.input_cache_read) * 1_000_000; - const cacheWriteCost = toNumber(model.pricing?.input_cache_write) * 1_000_000; + const inputCost = roundCost(toNumber(model.pricing?.input) * 1_000_000); + const outputCost = roundCost(toNumber(model.pricing?.output) * 1_000_000); + const cacheReadCost = roundCost(toNumber(model.pricing?.input_cache_read) * 1_000_000); + const cacheWriteCost = roundCost(toNumber(model.pricing?.input_cache_write) * 1_000_000); models.push({ id: model.id, @@ -378,6 +568,7 @@ async function loadModelsDevData(): Promise[]> { const data = await response.json(); const models: Model[] = []; + const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map(); // Process Amazon Bedrock models if (data["amazon-bedrock"]?.models) { @@ -448,6 +639,13 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.google.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + let source = m; + if (modelId === "gemini-flash-latest") { + source = (data.google.models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; + } + if (modelId === "gemini-flash-lite-latest") { + source = (data.google.models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; + } models.push({ id: modelId, @@ -455,16 +653,57 @@ async function loadModelsDevData(): Promise[]> { api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: m.reasoning === true, - input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + reasoning: source.reasoning === true, + input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, + input: source.cost?.input || 0, + output: source.cost?.output || 0, + cacheRead: source.cost?.cache_read || 0, + cacheWrite: source.cost?.cache_write || 0, }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, + contextWindow: source.limit?.context || 4096, + maxTokens: source.limit?.output || 4096, + }); + } + } + + // Process Google Vertex Gemini models. The google-vertex models.dev catalog also includes + // Claude, OpenAI, and other MaaS models that do not use the @google/genai Gemini streaming + // path implemented by our google-vertex provider. + if (data["google-vertex"]?.models) { + for (const [modelId, model] of Object.entries(data["google-vertex"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + if (!modelId.startsWith("gemini-")) continue; + if (modelId === "gemini-3.1-flash-lite-preview") continue; + let source = m; + if (modelId === "gemini-flash-latest") { + source = (data["google-vertex"].models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; + } + if (modelId === "gemini-flash-lite-latest") { + source = (data["google-vertex"].models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; + } + + // models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that + // do not match the official Gemini API standard pricing table. pi only accounts + // cachedContentTokenCount as cacheRead. + const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0; + models.push({ + id: modelId, + name: m.name || modelId, + api: "google-vertex", + provider: "google-vertex", + baseUrl: VERTEX_BASE_URL, + reasoning: source.reasoning === true, + input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: source.cost?.input || 0, + output: source.cost?.output || 0, + cacheRead, + cacheWrite: 0, + }, + contextWindow: source.limit?.context || 4096, + maxTokens: source.limit?.output || 4096, }); } } @@ -656,34 +895,45 @@ async function loadModelsDevData(): Promise[]> { } // Process zAi models - if (data["zai-coding-plan"]?.models) { - for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); + const zaiCodingPlanVariants = [ + { provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4" }, + { provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" }, + ] as const; - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - reasoning: m.reasoning === true, - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); + if (data["zai-coding-plan"]?.models) { + for (const { provider, baseUrl } of zaiCodingPlanVariants) { + for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); + + const isGlm52 = modelId === "glm-5.2"; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(isGlm52 ? { supportsReasoningEffort: true } : {}), + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } } } @@ -704,7 +954,7 @@ async function loadModelsDevData(): Promise[]> { cost: { input: m.cost?.input || 0, output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, + cacheRead: m.cost?.cache_read ?? (m.cost?.input ? roundCost(m.cost.input * 0.1) : 0), cacheWrite: m.cost?.cache_write || 0, }, contextWindow: m.limit?.context || 4096, @@ -779,6 +1029,40 @@ async function loadModelsDevData(): Promise[]> { } } + // Process NVIDIA NIM models + if (data.nvidia?.models) { + for (const [modelId, model] of Object.entries(data.nvidia.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + if (!m.modalities?.input?.includes("text")) continue; + if (!m.modalities?.output?.includes("text")) continue; + + const liveModelId = nvidiaNimModelIds.get(modelId) ?? nvidiaNimModelIds.get(normalizeNvidiaModelId(modelId)); + if (!liveModelId) continue; + if (NVIDIA_NIM_UNSUPPORTED_MODELS.has(liveModelId)) continue; + + models.push({ + id: liveModelId, + name: m.name || liveModelId, + api: "openai-completions", + provider: "nvidia", + baseUrl: NVIDIA_BASE_URL, + headers: { ...NVIDIA_HEADERS }, + reasoning: m.reasoning === true, + input: m.modalities.input.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + compat: NVIDIA_OPENAI_COMPAT, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + // Process Together AI models const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"]; if (togetherProvider?.models) { @@ -855,6 +1139,16 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } + if (variant.provider === "opencode" && modelId === "grok-build-0.1") { + compat = { ...(compat ?? {}), supportsReasoningEffort: false }; + } + + if ((variant.provider === "opencode" || variant.provider === "opencode-go") && modelId === "kimi-k2.6") { + // OpenCode Kimi K2.6 accepts Anthropic-style thinking objects + // and rejects string thinking values or combined reasoning_effort. + compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; + } + // Fix known mismatches between models.dev npm data and actual // OpenCode Go endpoint behaviour. models.dev reports these models // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't @@ -875,6 +1169,17 @@ async function loadModelsDevData(): Promise[]> { } } + if (api === "openai-completions") { + compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; + if ( + OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS.has( + `${variant.provider}:${modelId}`, + ) + ) { + compat = { ...compat, supportsLongCacheRetention: false }; + } + } + models.push({ id: modelId, name: m.name || modelId, @@ -1033,13 +1338,29 @@ async function loadModelsDevData(): Promise[]> { supportsReasoningEffort: false, maxTokensField: "max_tokens", supportsStrictMode: false, + thinkingFormat: "deepseek", + }; + const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record => { + const providerModels = data[key]?.models as Record | undefined; + return providerModels ? { ...providerModels } : {}; + }; + const moonshotModels = { + moonshotai: getMoonshotProviderModels("moonshotai"), + "moonshotai-cn": getMoonshotProviderModels("moonshotai-cn"), }; - for (const { key, provider, baseUrl } of moonshotVariants) { - if (!data[key]?.models) continue; + // models.dev can lag the CN catalog while the global Moonshot catalog already + // has the model. Mirror selected current model IDs into moonshotai-cn until + // upstream CN metadata catches up. + for (const modelId of MOONSHOT_CN_MIRRORED_MODEL_IDS) { + const model = moonshotModels.moonshotai[modelId]; + if (model && !moonshotModels["moonshotai-cn"][modelId]) { + moonshotModels["moonshotai-cn"][modelId] = model; + } + } - for (const [modelId, model] of Object.entries(data[key].models)) { - const m = model as ModelsDevModel; + for (const { key, provider, baseUrl } of moonshotVariants) { + for (const [modelId, m] of Object.entries(moonshotModels[key])) { if (m.tool_call !== true) continue; models.push({ @@ -1083,6 +1404,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xiaomi.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (provider.startsWith("xiaomi-token-plan-") && modelId === "mimo-v2-flash") continue; models.push({ id: modelId, @@ -1171,6 +1493,11 @@ async function generateModels() { candidate.contextWindow = 272000; candidate.maxTokens = 128000; } + // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); + // the actual max output is 128000. Also propagates to the derived Azure clone. + if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") { + candidate.maxTokens = 128000; + } // Keep selected OpenRouter model metadata stable until upstream settles. if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") { candidate.cost.input = 0.41; @@ -1178,12 +1505,23 @@ async function generateModels() { candidate.cost.cacheRead = 0.07; candidate.maxTokens = 4096; } + if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) { + candidate.compat = { + ...candidate.compat, + supportsDeveloperRole: false, + requiresReasoningContentOnAssistantMessages: true, + }; + } if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") { candidate.cost.input = 0.6; candidate.cost.output = 1.9; candidate.cost.cacheRead = 0.119; } - + if (candidate.provider === "fireworks" && candidate.id === "accounts/fireworks/models/glm-5p2") { + candidate.api = "openai-completions"; + candidate.baseUrl = "https://api.fireworks.ai/inference/v1"; + candidate.compat = { supportsStore: false, supportsDeveloperRole: false }; + } } @@ -1250,6 +1588,27 @@ async function generateModels() { }); } + // Add missing Claude Opus 4.8 + if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-opus-4-8")) { + allModels.push({ + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + }); + } + // Add missing Claude Sonnet 4.6 if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-sonnet-4-6")) { allModels.push({ @@ -1451,33 +1810,72 @@ async function generateModels() { ]; allModels.push(...deepseekV4Models); + const antLingCompat: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsLongCacheRetention: false, + }; + const antLingModels: Model<"openai-completions">[] = [ + { + id: "Ling-2.6-flash", + name: "Ling 2.6 Flash", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: false, + input: ["text"], + cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: antLingCompat, + }, + { + id: "Ling-2.6-1T", + name: "Ling 2.6 1T", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: false, + input: ["text"], + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: antLingCompat, + }, + { + id: "Ring-2.6-1T", + name: "Ring 2.6 1T", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: true, + input: ["text"], + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: { ...antLingCompat, thinkingFormat: "ant-ling" }, + }, + ]; + allModels.push(...antLingModels); + for (const candidate of allModels) { if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { + const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { ...candidate.compat, - ...(candidate.provider === "openrouter" + ...(preservesNativeReasoningEffort ? { requiresReasoningContentOnAssistantMessages: deepseekCompat.requiresReasoningContentOnAssistantMessages, - thinkingFormat: deepseekCompat.thinkingFormat, } : deepseekCompat), }; - mergeThinkingLevelMap(candidate, DEEPSEEK_V4_THINKING_LEVEL_MAP); } } - const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed"]); - - for (const candidate of allModels) { - if ( - (candidate.provider === "minimax" || candidate.provider === "minimax-cn") && - minimaxDirectSupportedIds.has(candidate.id) - ) { - candidate.contextWindow = 204800; - candidate.maxTokens = 131072; - } - } + const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]); for (let i = allModels.length - 1; i >= 0; i--) { const candidate = allModels[i]; @@ -1494,32 +1892,9 @@ async function generateModels() { // Context window is based on observed server limits (400s above ~272k), not marketing numbers. const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const CODEX_CONTEXT = 272000; + const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ - { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", @@ -1529,7 +1904,7 @@ async function generateModels() { reasoning: true, input: ["text"], cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, + contextWindow: CODEX_SPARK_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { @@ -1665,167 +2040,38 @@ async function generateModels() { }); } - const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; - const vertexModels: Model<"google-vertex">[] = [ - { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, + // Add "fusion" alias for openrouter/fusion. OpenRouter exposes Fusion as a + // router alias/plugin entry point; its model metadata does not advertise + // tools, but the alias resolves to a concrete model that can invoke caller + // tools and has the openrouter:fusion server tool auto-injected. + if (!allModels.some(m => m.provider === "openrouter" && m.id === "openrouter/fusion")) { + allModels.push({ + id: "openrouter/fusion", + name: "OpenRouter: Fusion", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, + input: ["text"], + cost: { + // we dont know about the costs because Fusion routes to multiple models + // and then charges you for the underlying used models + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 1000000, - maxTokens: 64000, - }, - { - id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-3.1-pro-preview-customtools", - name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.15, output: 0.6, cacheRead: 0.0375, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 8192, - }, - { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 1.25, output: 5, cacheRead: 0.3125, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.0375, output: 0.15, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - ]; - allModels.push(...vertexModels); + maxTokens: 30000, + }); + } + // Azure Foundry deploys these with larger context windows than OpenAI's own API, + // which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs. + const AZURE_CONTEXT_WINDOW_OVERRIDES: Record = { + "gpt-5.4": 1050000, + "gpt-5.5": 1050000, + }; const azureOpenAiModels: Model[] = allModels .filter((model) => model.provider === "openai" && model.api === "openai-responses") .map((model) => ({ @@ -1833,6 +2079,7 @@ async function generateModels() { api: "azure-openai-responses", provider: "azure-openai-responses", baseUrl: "", + contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow, })); allModels.push(...azureOpenAiModels); diff --git a/packages/ai/src/base.ts b/packages/ai/src/base.ts new file mode 100644 index 00000000..f1a8a110 --- /dev/null +++ b/packages/ai/src/base.ts @@ -0,0 +1,45 @@ +export type { Static, TSchema } from "typebox"; +export { Type } from "typebox"; + +export * from "./api-registry.ts"; +export * from "./env-api-keys.ts"; +export * from "./image-models.ts"; +export * from "./images.ts"; +export * from "./images-api-registry.ts"; +export * from "./models.ts"; +export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts"; +export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts"; +export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts"; +export * from "./providers/faux.ts"; +export type { GoogleOptions } from "./providers/google.ts"; +export type { GoogleThinkingLevel } from "./providers/google-shared.ts"; +export type { GoogleVertexOptions } from "./providers/google-vertex.ts"; +export type { MistralOptions } from "./providers/mistral.ts"; +export type { + OpenAICodexResponsesOptions, + OpenAICodexWebSocketDebugStats, +} from "./providers/openai-codex-responses.ts"; +export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts"; +export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts"; +export * from "./session-resources.ts"; +export * from "./stream.ts"; +export * from "./types.ts"; +export * from "./utils/diagnostics.ts"; +export * from "./utils/event-stream.ts"; +export * from "./utils/json-parse.ts"; +export type { + OAuthAuthInfo, + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, + OAuthSelectOption, + OAuthSelectPrompt, +} from "./utils/oauth/types.ts"; +export * from "./utils/overflow.ts"; +export * from "./utils/typebox-helpers.ts"; +export * from "./utils/validation.ts"; diff --git a/packages/ai/src/bedrock-provider.ts b/packages/ai/src/bedrock-provider.ts index cf08b33e..f5aa3998 100644 --- a/packages/ai/src/bedrock-provider.ts +++ b/packages/ai/src/bedrock-provider.ts @@ -1,4 +1,6 @@ -import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts"; +import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts"; + +export { register }; export const bedrockProviderModule = { streamBedrock, diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 38ee7e34..21699dbd 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -42,9 +42,23 @@ async function login(providerId: OAuthProviderId): Promise { if (info.instructions) console.log(info.instructions); console.log(); }, + onDeviceCode: (info) => { + console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`); + console.log(`Enter code: ${info.userCode}`); + console.log(); + }, onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, + onSelect: async (p) => { + console.log(`\n${p.message}`); + for (let i = 0; i < p.options.length; i++) { + console.log(` ${i + 1}. ${p.options[i].label}`); + } + const choice = await promptFn(`Enter number (1-${p.options.length}):`); + const index = parseInt(choice, 10) - 1; + return p.options[index]?.id; + }, onProgress: (msg) => console.log(msg), }); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index a00e8470..7bd955e1 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -23,44 +23,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import type { KnownProvider } from "./types.ts"; - -let _procEnvCache: Map | null = null; - -/** - * Fallback for https://github.com/oven-sh/bun/issues/27802 - * Bun compiled binaries have an empty `process.env` inside sandbox - * environments on Linux. We can recover the env from `/proc/self/environ`. - */ -function getProcEnv(key: string): string | undefined { - if (!process.versions?.bun) return undefined; - if (typeof process === "undefined") return undefined; - - // If process.env already has entries, the bug is not triggered. - if (Object.keys(process.env).length > 0) return undefined; - - if (_procEnvCache === null) { - _procEnvCache = new Map(); - try { - const { readFileSync } = require("node:fs") as typeof import("node:fs"); - const data = readFileSync("/proc/self/environ", "utf-8"); - for (const entry of data.split("\0")) { - const idx = entry.indexOf("="); - if (idx > 0) { - _procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); - } - } - } catch { - // /proc/self/environ may not be readable. - } - } - - return _procEnvCache.get(key); -} +import type { KnownProvider, ProviderEnv } from "./types.ts"; +import { getProviderEnvValue } from "./utils/provider-env.ts"; let cachedVertexAdcCredentialsExists: boolean | null = null; -function hasVertexAdcCredentials(): boolean { +function hasVertexAdcCredentials(env?: ProviderEnv): boolean { + const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS; + if (explicitCredentialsPath) { + return _existsSync ? _existsSync(explicitCredentialsPath) : false; + } + if (cachedVertexAdcCredentialsExists === null) { // If node modules haven't loaded yet (async import race at startup), // return false WITHOUT caching so the next call retries once they're ready. @@ -75,7 +48,7 @@ function hasVertexAdcCredentials(): boolean { } // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) - const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS"); + const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env); if (gacPath) { cachedVertexAdcCredentialsExists = _existsSync(gacPath); } else { @@ -99,8 +72,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { } const envMap: Record = { + "ant-ling": "ANT_LING_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", + nvidia: "NVIDIA_API_KEY", deepseek: "DEEPSEEK_API_KEY", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", @@ -110,6 +85,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", zai: "ZAI_API_KEY", + "zai-coding-cn": "ZAI_CODING_CN_API_KEY", mistral: "MISTRAL_API_KEY", minimax: "MINIMAX_API_KEY", "minimax-cn": "MINIMAX_CN_API_KEY", @@ -140,13 +116,13 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { * credential sources such as AWS profiles, AWS IAM credentials, and Google * Application Default Credentials. */ -export function findEnvKeys(provider: KnownProvider): string[] | undefined; -export function findEnvKeys(provider: string): string[] | undefined; -export function findEnvKeys(provider: string): string[] | undefined { +export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined { const envVars = getApiKeyEnvVars(provider); if (!envVars) return undefined; - const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar)); + const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env)); return found.length > 0 ? found : undefined; } @@ -155,25 +131,22 @@ export function findEnvKeys(provider: string): string[] | undefined { * * Will not return API keys for providers that require OAuth tokens. */ -export function getEnvApiKey(provider: KnownProvider): string | undefined; -export function getEnvApiKey(provider: string): string | undefined; -export function getEnvApiKey(provider: string): string | undefined { - const envKeys = findEnvKeys(provider); +export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined { + const envKeys = findEnvKeys(provider, env); if (envKeys?.[0]) { - return process.env[envKeys[0]] || getProcEnv(envKeys[0]); + return getProviderEnvValue(envKeys[0], env); } // Vertex AI supports either an explicit API key or Application Default Credentials. // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { - const hasCredentials = hasVertexAdcCredentials(); + const hasCredentials = hasVertexAdcCredentials(env); const hasProject = !!( - process.env.GOOGLE_CLOUD_PROJECT || - process.env.GCLOUD_PROJECT || - getProcEnv("GOOGLE_CLOUD_PROJECT") || - getProcEnv("GCLOUD_PROJECT") + getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env) ); - const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION")); + const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env); if (hasCredentials && hasProject && hasLocation) { return ""; @@ -189,18 +162,12 @@ export function getEnvApiKey(provider: string): string | undefined { // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) if ( - process.env.AWS_PROFILE || - (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || - process.env.AWS_BEARER_TOKEN_BEDROCK || - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || - process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || - process.env.AWS_WEB_IDENTITY_TOKEN_FILE || - getProcEnv("AWS_PROFILE") || - (getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) || - getProcEnv("AWS_BEARER_TOKEN_BEDROCK") || - getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || - getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") || - getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE") + getProviderEnvValue("AWS_PROFILE", env) || + (getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) || + getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) || + getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env) ) { return ""; } diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 09c74180..7545a9f1 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -95,6 +95,21 @@ export const IMAGE_MODELS = { cacheWrite: 0.08333333333333334, }, } satisfies ImagesModel<"openrouter-images">, + "google/gemini-3-pro-image": { + id: "google/gemini-3-pro-image", + name: "Google: Nano Banana Pro (Gemini 3 Pro Image)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["image", "text"], + output: ["image", "text"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-3-pro-image-preview": { id: "google/gemini-3-pro-image-preview", name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)", @@ -110,6 +125,21 @@ export const IMAGE_MODELS = { cacheWrite: 0.375, }, } satisfies ImagesModel<"openrouter-images">, + "google/gemini-3.1-flash-image": { + id: "google/gemini-3.1-flash-image", + name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["image", "text"], + output: ["image", "text"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-3.1-flash-image-preview": { id: "google/gemini-3.1-flash-image-preview", name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)", diff --git a/packages/ai/src/images-api-registry.ts b/packages/ai/src/images-api-registry.ts index 4758439b..5335a926 100644 --- a/packages/ai/src/images-api-registry.ts +++ b/packages/ai/src/images-api-registry.ts @@ -51,3 +51,7 @@ export function registerImagesApiProvider, }, + "ant-ling": { + "Ling-2.6-1T": { + id: "Ling-2.6-1T", + name: "Ling 2.6 1T", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.06, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "Ling-2.6-flash": { + id: "Ling-2.6-flash", + name: "Ling 2.6 Flash", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.01, + output: 0.02, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "Ring-2.6-1T": { + id: "Ring-2.6-1T", + name: "Ring 2.6 1T", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"thinkingFormat":"ant-ling"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, + input: ["text"], + cost: { + input: 0.06, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + }, "anthropic": { "claude-3-5-haiku-20241022": { id: "claude-3-5-haiku-20241022", @@ -1810,7 +1876,9 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -1963,6 +2031,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -1981,6 +2050,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -1999,7 +2069,9 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 5, @@ -2084,6 +2156,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -2373,7 +2446,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -2606,7 +2679,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.4-mini": { @@ -2678,7 +2751,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.5-pro": { @@ -2688,7 +2761,7 @@ export const MODELS = { provider: "azure-openai-responses", baseUrl: "", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -2981,7 +3054,9 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -3066,6 +3141,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -3084,6 +3160,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -3102,7 +3179,9 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 5, @@ -3153,6 +3232,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -3850,11 +3930,12 @@ export const MODELS = { "accounts/fireworks/models/glm-5p2": { id: "accounts/fireworks/models/glm-5p2", name: "GLM 5.2", - api: "anthropic-messages", + api: "openai-completions", provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + baseUrl: "https://api.fireworks.ai/inference/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false}, reasoning: true, + thinkingLevelMap: {"off":"none","minimal":null,"low":"high","medium":"high","xhigh":"max"}, input: ["text"], cost: { input: 1.4, @@ -3864,7 +3945,7 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "accounts/fireworks/models/gpt-oss-120b": { id: "accounts/fireworks/models/gpt-oss-120b", name: "GPT OSS 120B", @@ -4128,6 +4209,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -4147,8 +4229,9 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], cost: { input: 5, @@ -4166,7 +4249,9 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], cost: { input: 5, @@ -4222,7 +4307,9 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, + thinkingLevelMap: {"minimal":"low","xhigh":"max"}, input: ["text", "image"], cost: { input: 3, @@ -4700,11 +4787,12 @@ export const MODELS = { provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, + input: 1.5, + output: 9, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 1048576, @@ -4717,10 +4805,11 @@ export const MODELS = { provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, + input: 0.25, + output: 1.5, cacheRead: 0.025, cacheWrite: 0, }, @@ -4765,94 +4854,9 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, }, "google-vertex": { - "gemini-1.5-flash": { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-1.5-flash-8b": { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.0375, - output: 0.15, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-2.0-flash-lite": { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-vertex">, "gemini-2.5-flash": { id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Vertex)", + name: "Gemini 2.5 Flash", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4869,24 +4873,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-2.5-flash-lite": { id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-vertex">, - "gemini-2.5-flash-lite-preview-09-2025": { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", + name: "Gemini 2.5 Flash-Lite", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4903,7 +4890,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-2.5-pro": { id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Vertex)", + name: "Gemini 2.5 Pro", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4920,7 +4907,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-3-flash-preview": { id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Vertex)", + name: "Gemini 3 Flash Preview", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4936,27 +4923,27 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-vertex">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Vertex)", + "gemini-3.1-flash-lite": { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 2, - output: 12, - cacheRead: 0.2, + input: 0.25, + output: 1.5, + cacheRead: 0.025, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 64000, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"google-vertex">, "gemini-3.1-pro-preview": { id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro Preview (Vertex)", + name: "Gemini 3.1 Pro Preview", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4974,7 +4961,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-3.1-pro-preview-customtools": { id: "gemini-3.1-pro-preview-customtools", - name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)", + name: "Gemini 3.1 Pro Preview Custom Tools", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4990,6 +4977,60 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-vertex">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-flash-latest": { + id: "gemini-flash-latest", + name: "Gemini Flash Latest", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-flash-lite-latest": { + id: "gemini-flash-lite-latest", + name: "Gemini Flash-Lite Latest", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, }, "groq": { "llama-3.1-8b-instant": { @@ -5602,6 +5643,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "anthropic-messages", + provider: "minimax", + baseUrl: "https://api.minimax.io/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, }, "minimax-cn": { "MiniMax-M2.7": { @@ -5638,6 +5696,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "anthropic-messages", + provider: "minimax-cn", + baseUrl: "https://api.minimaxi.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, }, "mistral": { "codestral-latest": { @@ -5651,7 +5726,7 @@ export const MODELS = { cost: { input: 0.3, output: 0.9, - cacheRead: 0, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 256000, @@ -5668,7 +5743,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5685,7 +5760,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5702,7 +5777,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 128000, @@ -5719,7 +5794,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5736,7 +5811,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5753,7 +5828,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5787,7 +5862,7 @@ export const MODELS = { cost: { input: 2, output: 5, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, @@ -5804,7 +5879,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 128000, @@ -5821,7 +5896,7 @@ export const MODELS = { cost: { input: 0.04, output: 0.04, - cacheRead: 0, + cacheRead: 0.004, cacheWrite: 0, }, contextWindow: 128000, @@ -5838,7 +5913,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.1, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5855,7 +5930,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 131072, @@ -5872,7 +5947,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -5889,7 +5964,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -5906,7 +5981,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 131072, @@ -5923,7 +5998,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5940,7 +6015,7 @@ export const MODELS = { cost: { input: 1.5, output: 7.5, - cacheRead: 0, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, @@ -5974,7 +6049,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5991,7 +6066,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6008,7 +6083,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -6025,7 +6100,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 256000, @@ -6042,7 +6117,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 256000, @@ -6059,7 +6134,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.25, - cacheRead: 0, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 8000, @@ -6076,7 +6151,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6093,7 +6168,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 64000, @@ -6110,7 +6185,7 @@ export const MODELS = { cost: { input: 0.7, output: 0.7, - cacheRead: 0, + cacheRead: 0.07, cacheWrite: 0, }, contextWindow: 32000, @@ -6127,7 +6202,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6144,7 +6219,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, @@ -6158,7 +6233,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6176,7 +6251,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6194,7 +6269,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6212,7 +6287,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6230,7 +6305,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6248,7 +6323,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6266,7 +6341,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6284,8 +6359,9 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { input: 0.95, @@ -6302,8 +6378,9 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { input: 1.9, @@ -6322,7 +6399,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6340,7 +6417,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6358,7 +6435,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6376,7 +6453,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6394,7 +6471,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6412,7 +6489,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6430,7 +6507,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6448,8 +6525,9 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { input: 0.95, @@ -6466,8 +6544,9 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { input: 1.9, @@ -6479,6 +6558,369 @@ export const MODELS = { maxTokens: 262144, } satisfies Model<"openai-completions">, }, + "nvidia": { + "meta/llama-3.1-70b-instruct": { + id: "meta/llama-3.1-70b-instruct", + name: "Llama 3.1 70b Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.1-8b-instruct": { + id: "meta/llama-3.1-8b-instruct", + name: "Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.2-11b-vision-instruct": { + id: "meta/llama-3.2-11b-vision-instruct", + name: "Llama 3.2 11b Vision Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.2-90b-vision-instruct": { + id: "meta/llama-3.2-90b-vision-instruct", + name: "Llama-3.2-90B-Vision-Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta/llama-3.3-70b-instruct": { + id: "meta/llama-3.3-70b-instruct", + name: "Llama 3.3 70b Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-3-675b-instruct-2512": { + id: "mistralai/mistral-large-3-675b-instruct-2512", + name: "Mistral Large 3 675B Instruct 2512", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-4-119b-2603": { + id: "mistralai/mistral-small-4-119b-2603", + name: "mistral-small-4-119b-2603", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2.6": { + id: "moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "nemotron-3-nano-30b-a3b", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": { + id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + name: "Nemotron 3 Nano Omni", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-super-120b-a12b": { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "Nemotron 3 Super", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 0.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B A55B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "nvidia/nvidia-nemotron-nano-9b-v2": { + id: "nvidia/nvidia-nemotron-nano-9b-v2", + name: "nvidia-nemotron-nano-9b-v2", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT-OSS-120B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3.5-122b-a10b": { + id: "qwen/qwen3.5-122b-a10b", + name: "Qwen3.5 122B-A10B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "stepfun-ai/step-3.5-flash": { + id: "stepfun-ai/step-3.5-flash", + name: "Step 3.5 Flash", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "stepfun-ai/step-3.7-flash": { + id: "stepfun-ai/step-3.7-flash", + name: "Step 3.7 Flash", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.1": { + id: "z-ai/glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, "openai": { "gpt-4": { id: "gpt-4", @@ -6756,7 +7198,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -7053,7 +7495,7 @@ export const MODELS = { provider: "openai", baseUrl: "https://api.openai.com/v1", reasoning: true, - thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + thinkingLevelMap: {"off":"none","xhigh":"xhigh","minimal":null}, input: ["text", "image"], cost: { input: 5, @@ -7071,7 +7513,7 @@ export const MODELS = { provider: "openai", baseUrl: "https://api.openai.com/v1", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -7220,42 +7662,6 @@ export const MODELS = { } satisfies Model<"openai-responses">, }, "openai-codex": { - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, - "gpt-5.3-codex": { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, "gpt-5.3-codex-spark": { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", @@ -7271,7 +7677,7 @@ export const MODELS = { cacheRead: 0.175, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.4": { @@ -7336,6 +7742,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7404,6 +7811,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -7422,6 +7830,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -7440,7 +7849,9 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 5, @@ -7491,6 +7902,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -7508,7 +7920,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7527,7 +7939,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7546,7 +7958,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7619,6 +8031,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7636,6 +8049,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7924,7 +8338,7 @@ export const MODELS = { provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -7941,7 +8355,9 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, input: ["text", "image"], cost: { input: 1, @@ -7958,6 +8374,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -7975,6 +8392,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -7992,6 +8410,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8009,6 +8428,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8026,6 +8446,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -8043,6 +8464,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8060,6 +8482,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8113,7 +8536,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8132,7 +8555,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8151,6 +8574,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8168,6 +8592,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8185,7 +8610,9 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], cost: { input: 0.95, @@ -8202,6 +8629,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8219,6 +8647,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8236,6 +8665,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8253,6 +8683,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8287,7 +8718,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"qwen"}, + compat: {"thinkingFormat":"qwen","maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8429,8 +8860,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, + input: 0.8, + output: 3.2, cacheRead: 0, cacheWrite: 0, }, @@ -8463,7 +8894,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, + input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, @@ -8499,7 +8930,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -8635,6 +9066,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 5, @@ -8652,6 +9084,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -8723,7 +9156,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.25, - output: 0.7999999999999999, + output: 0.8, cacheRead: 0.06, cacheWrite: 0, }, @@ -8841,8 +9274,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -8909,8 +9342,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.20020000000000002, - output: 0.8000999999999999, + input: 0.2002, + output: 0.8001, cacheRead: 0, cacheWrite: 0, }, @@ -8926,7 +9359,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.77, cacheRead: 0.135, cacheWrite: 0, @@ -8944,7 +9377,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.21, - output: 0.7899999999999999, + output: 0.79, cacheRead: 0.13, cacheWrite: 0, }, @@ -8978,7 +9411,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.5, - output: 2.1500000000000004, + output: 2.15, cacheRead: 0.35, cacheWrite: 0, }, @@ -9042,9 +9475,9 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { input: 0.09, @@ -9061,9 +9494,9 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { input: 0.435, @@ -9103,7 +9536,7 @@ export const MODELS = { input: 0.3, output: 2.5, cacheRead: 0.03, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9117,10 +9550,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9134,10 +9567,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9204,8 +9637,8 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.049999999999999996, - cacheWrite: 0.08333333333333334, + cacheRead: 0.05, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9221,10 +9654,10 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, - contextWindow: 65536, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "google/gemini-3.1-flash-lite": { @@ -9238,8 +9671,8 @@ export const MODELS = { cost: { input: 0.25, output: 1.5, - cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheRead: 0.025, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9255,8 +9688,8 @@ export const MODELS = { cost: { input: 0.25, output: 1.5, - cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheRead: 0.025, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9272,7 +9705,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048576, @@ -9289,7 +9722,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048756, @@ -9307,7 +9740,7 @@ export const MODELS = { input: 1.5, output: 9, cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9321,7 +9754,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.049999999999999996, + input: 0.05, output: 0.15, cacheRead: 0, cacheWrite: 0, @@ -9412,7 +9845,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 32768, } satisfies Model<"openai-completions">, "ibm-granite/granite-4.1-8b": { id: "ibm-granite/granite-4.1-8b", @@ -9423,9 +9856,9 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.09999999999999999, - cacheRead: 0.049999999999999996, + input: 0.05, + output: 0.1, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 131072, @@ -9443,7 +9876,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 128000, @@ -9514,8 +9947,8 @@ export const MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 80000, + contextWindow: 262144, + maxTokens: 144000, } satisfies Model<"openai-completions">, "liquid/lfm-2.5-1.2b-thinking:free": { id: "liquid/lfm-2.5-1.2b-thinking:free", @@ -9543,8 +9976,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -9577,7 +10010,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.32, cacheRead: 0, cacheWrite: 0, @@ -9628,7 +10061,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -9645,7 +10078,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2.2, cacheRead: 0, cacheWrite: 0, @@ -9697,8 +10130,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.15, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + output: 0.9, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 204800, @@ -9715,7 +10148,7 @@ export const MODELS = { cost: { input: 0.25, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 204800, @@ -9736,7 +10169,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 512000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -9748,7 +10181,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0.03, cacheWrite: 0, }, @@ -9764,7 +10197,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -9781,8 +10214,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, - output: 0.19999999999999998, + input: 0.2, + output: 0.2, cacheRead: 0.02, cacheWrite: 0, }, @@ -9798,8 +10231,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0.01, cacheWrite: 0, }, @@ -9834,7 +10267,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, @@ -9851,7 +10284,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 131072, @@ -9868,7 +10301,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -9883,7 +10316,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -9917,7 +10350,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -9951,7 +10384,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.6, cacheRead: 0.02, cacheWrite: 0, @@ -9986,7 +10419,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.075, - output: 0.19999999999999998, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -10004,7 +10437,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 65536, @@ -10019,7 +10452,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0, @@ -10036,7 +10469,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5700000000000001, + input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0, @@ -10072,7 +10505,7 @@ export const MODELS = { cost: { input: 0.6, output: 2.5, - cacheRead: 0, + cacheRead: 0.6, cacheWrite: 0, }, contextWindow: 262144, @@ -10101,6 +10534,7 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, input: ["text", "image"], cost: { @@ -10122,7 +10556,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.612, - output: 3.0690000000000004, + output: 3.069, cacheRead: 0.1296, cacheWrite: 0, }, @@ -10155,8 +10589,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -10172,8 +10606,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, + input: 0.05, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -10224,7 +10658,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.09, - output: 0.44999999999999996, + output: 0.45, cacheRead: 0, cacheWrite: 0, }, @@ -10259,7 +10693,7 @@ export const MODELS = { cost: { input: 0.5, output: 2.2, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1000000, @@ -10444,13 +10878,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.09999999999999999, + input: 0.4, + output: 1.6, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1047576, - maxTokens: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-4.1-nano": { id: "openai/gpt-4.1-nano", @@ -10461,9 +10895,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, + input: 0.1, + output: 0.4, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -10480,7 +10914,7 @@ export const MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, @@ -10616,11 +11050,11 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-5-nano": { id: "openai/gpt-5-nano", @@ -10631,8 +11065,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, + input: 0.05, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -10684,11 +11118,11 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", @@ -10735,7 +11169,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -10775,7 +11209,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 16384, + maxTokens: 32000, } satisfies Model<"openai-completions">, "openai/gpt-5.2-codex": { id: "openai/gpt-5.2-codex", @@ -10895,7 +11329,7 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0, @@ -10946,7 +11380,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","off":null,"minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -11280,6 +11714,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openrouter/fusion": { + id: "openrouter/fusion", + name: "OpenRouter: Fusion", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, "openrouter/owl-alpha": { id: "openrouter/owl-alpha", name: "Owl Alpha", @@ -11306,9 +11757,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 0.39999999999999997, - cacheRead: 0.09999999999999999, + input: 0.2, + output: 0.4, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -11340,9 +11791,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.19999999999999998, - cacheRead: 0.049999999999999996, + input: 0.1, + output: 0.2, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -11374,7 +11825,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0, @@ -11392,7 +11843,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.36, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -11409,7 +11860,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.04, - output: 0.09999999999999999, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -11427,7 +11878,7 @@ export const MODELS = { cost: { input: 0.26, output: 0.78, - cacheRead: 0.052000000000000005, + cacheRead: 0.052, cacheWrite: 0.325, }, contextWindow: 1000000, @@ -11476,7 +11927,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.24, cacheRead: 0, cacheWrite: 0, @@ -11493,8 +11944,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.45499999999999996, - output: 1.8199999999999998, + input: 0.455, + output: 1.82, cacheRead: 0, cacheWrite: 0, }, @@ -11511,7 +11962,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.09, - output: 0.09999999999999999, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -11527,9 +11978,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0.09999999999999999, + input: 0.1, + output: 0.1, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -11579,7 +12030,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.08, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.08, cacheWrite: 0, }, @@ -11612,9 +12063,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, - cacheRead: 0.049999999999999996, + input: 0.05, + output: 0.4, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 131072, @@ -11630,7 +12081,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.22, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0, cacheWrite: 0, }, @@ -11681,7 +12132,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.11, - output: 0.7999999999999999, + output: 0.8, cacheRead: 0.07, cacheWrite: 0, }, @@ -11816,7 +12267,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.88, cacheRead: 0.11, cacheWrite: 0, @@ -11884,8 +12335,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.10400000000000001, - output: 0.41600000000000004, + input: 0.104, + output: 0.416, cacheRead: 0, cacheWrite: 0, }, @@ -11971,11 +12422,11 @@ export const MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 81920, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -11987,7 +12438,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.385, - output: 2.4499999999999997, + output: 2.45, cacheRead: 0, cacheWrite: 0, }, @@ -12003,13 +12454,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 256000, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", @@ -12055,7 +12506,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0, cacheWrite: 0.375, }, @@ -12071,7 +12522,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.28850000000000003, + input: 0.2885, output: 3.17, cacheRead: 0, cacheWrite: 0, @@ -12176,7 +12627,7 @@ export const MODELS = { input: 0.32, output: 1.28, cacheRead: 0.064, - cacheWrite: 0.39999999999999997, + cacheWrite: 0.4, }, contextWindow: 1000000, maxTokens: 65536, @@ -12190,8 +12641,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -12258,7 +12709,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0, @@ -12277,7 +12728,7 @@ export const MODELS = { cost: { input: 0.063, output: 0.21, - cacheRead: 0.020999999999999998, + cacheRead: 0.021, cacheWrite: 0, }, contextWindow: 262144, @@ -12292,7 +12743,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.16999999999999998, + input: 0.17, output: 0.43, cacheRead: 0, cacheWrite: 0, @@ -12309,8 +12760,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -12345,7 +12796,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -12362,7 +12813,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -12379,7 +12830,7 @@ export const MODELS = { cost: { input: 1, output: 2, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 256000, @@ -12447,7 +12898,7 @@ export const MODELS = { cost: { input: 0.13, output: 0.85, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 131072, @@ -12463,7 +12914,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0.11, cacheWrite: 0, }, @@ -12497,7 +12948,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0.055, cacheWrite: 0, }, @@ -12513,7 +12964,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 1.75, cacheRead: 0.08, cacheWrite: 0, @@ -12531,7 +12982,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -12583,11 +13034,11 @@ export const MODELS = { cost: { input: 0.98, output: 3.08, - cacheRead: 0.49, + cacheRead: 0.182, cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 65535, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { id: "z-ai/glm-5.2", @@ -12596,11 +13047,12 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { input: 1.2, output: 4.1, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1048576, @@ -12634,7 +13086,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -12686,7 +13138,7 @@ export const MODELS = { input: 1.5, output: 9, cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -12702,7 +13154,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048576, @@ -13213,7 +13665,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 4, cacheRead: 0, cacheWrite: 0, @@ -13283,7 +13735,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -13383,7 +13835,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 4, cacheRead: 0, cacheWrite: 0, @@ -13400,8 +13852,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.001, cacheWrite: 0.125, }, @@ -13417,7 +13869,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2.4, cacheRead: 0.04, cacheWrite: 0.5, @@ -13435,7 +13887,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 3.5999999999999996, + output: 3.6, cacheRead: 0, cacheWrite: 0, }, @@ -13453,7 +13905,7 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 0.625, }, contextWindow: 1000000, @@ -13485,8 +13937,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, + input: 0.4, + output: 1.6, cacheRead: 0.08, cacheWrite: 0.5, }, @@ -13519,7 +13971,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, + input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, @@ -13538,7 +13990,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -13601,6 +14053,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -13619,6 +14072,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -13637,7 +14091,9 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 5, @@ -13688,6 +14144,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -13726,7 +14183,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.25, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0, cacheWrite: 0, }, @@ -13744,7 +14201,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 256000, @@ -13929,8 +14386,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -13965,7 +14422,7 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -13982,7 +14439,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -14033,7 +14490,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -14083,7 +14540,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.14, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -14101,7 +14558,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 128000, @@ -14253,7 +14710,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.24, - output: 0.9700000000000001, + output: 0.97, cacheRead: 0, cacheWrite: 0, }, @@ -14269,7 +14726,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.16999999999999998, + input: 0.17, output: 0.66, cacheRead: 0, cacheWrite: 0, @@ -14423,7 +14880,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0, cacheWrite: 0, }, @@ -14439,7 +14896,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0, @@ -14456,7 +14913,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14473,7 +14930,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14490,8 +14947,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -14524,7 +14981,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0, @@ -14575,7 +15032,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14626,7 +15083,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5700000000000001, + input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0, @@ -14662,7 +15119,7 @@ export const MODELS = { cost: { input: 0.6, output: 3, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262114, @@ -14762,7 +15219,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.6, cacheRead: 0, cacheWrite: 0, @@ -14780,7 +15237,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.22999999999999998, + output: 0.23, cacheRead: 0, cacheWrite: 0, }, @@ -14830,9 +15287,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.09999999999999999, + input: 0.4, + output: 1.6, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1047576, @@ -14847,9 +15304,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, + input: 0.1, + output: 0.4, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -14951,7 +15408,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -14966,8 +15423,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, + input: 0.05, + output: 0.4, cacheRead: 0.005, cacheWrite: 0, }, @@ -15036,7 +15493,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -15230,7 +15687,7 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0, @@ -15281,7 +15738,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","off":null,"minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -15318,8 +15775,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, + input: 0.05, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -15505,7 +15962,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0, @@ -15522,9 +15979,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -15539,9 +15996,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -15558,7 +16015,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15575,7 +16032,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15592,7 +16049,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15609,7 +16066,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15626,7 +16083,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15643,7 +16100,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15660,7 +16117,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -15677,7 +16134,7 @@ export const MODELS = { cost: { input: 1, output: 2, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 256000, @@ -15692,7 +16149,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0, @@ -15711,7 +16168,7 @@ export const MODELS = { cost: { input: 1, output: 3, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -15777,7 +16234,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0, @@ -15795,7 +16252,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0.11, cacheWrite: 0, }, @@ -15829,8 +16286,8 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + output: 0.9, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 128000, @@ -15880,7 +16337,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.07, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -15897,7 +16354,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -15914,8 +16371,8 @@ export const MODELS = { input: ["text"], cost: { input: 1, - output: 3.1999999999999997, - cacheRead: 0.19999999999999998, + output: 3.2, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 202800, @@ -16222,24 +16679,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-ams": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", @@ -16332,24 +16771,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-cn": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", @@ -16442,24 +16863,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-sgp": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", @@ -16630,8 +17033,9 @@ export const MODELS = { api: "openai-completions", provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","supportsReasoningEffort":true,"zaiToolStream":true}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"high","medium":"high","high":"high","xhigh":"max"}, input: ["text"], cost: { input: 0, @@ -16661,4 +17065,115 @@ export const MODELS = { maxTokens: 131072, } satisfies Model<"openai-completions">, }, + "zai-coding-cn": { + "glm-4.5-air": { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "glm-4.7": { + id: "glm-4.7", + name: "GLM-4.7", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5-turbo": { + id: "glm-5-turbo", + name: "GLM-5-Turbo", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","supportsReasoningEffort":true,"zaiToolStream":true}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"high","medium":"high","high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5v-turbo": { + id: "glm-5v-turbo", + name: "GLM-5V-Turbo", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, } as const; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index e14a6c2f..9d9fa519 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -37,10 +37,13 @@ export function getModels( } export function calculateCost(model: Model, usage: Usage): Usage["cost"] { + // Anthropic charges 2x base input for 1h cache writes. + const longWrite = usage.cacheWrite1h ?? 0; + const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (model.cost.input / 1000000) * usage.input; usage.cost.output = (model.cost.output / 1000000) * usage.output; usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; - usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite; + usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; return usage.cost; } diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 429098a6..078b34ca 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -1,3 +1,4 @@ +import type { Agent as HttpsAgent } from "node:https"; import { BedrockRuntimeClient, type BedrockRuntimeClientConfig, @@ -18,17 +19,23 @@ import { type SystemContentBlock, type ToolChoice, type ToolConfiguration, + type ToolResultContentBlock, ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; -import type { DocumentType } from "@smithy/types"; +import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost } from "../models.ts"; import type { Api, AssistantMessage, CacheRetention, Context, + ImageContent, Model, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -43,7 +50,8 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; -import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts"; +import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -66,7 +74,7 @@ export interface BedrockOptions extends StreamOptions { * - "omitted": Thinking content is redacted but the signature still travels back * for multi-turn continuity, reducing time-to-first-text-token. * - * Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is + * Note: Anthropic's API default for Claude Opus 4.8 and Mythos Preview is * "omitted". We default to "summarized" here to keep behavior consistent with * older Claude 4 models. Only applies to Claude models on Bedrock. */ @@ -86,6 +94,8 @@ export interface BedrockOptions extends StreamOptions { type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +const EMPTY_TEXT_PLACEHOLDER = ""; + export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -115,18 +125,18 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt const blocks = output.content as Block[]; const config: BedrockRuntimeClientConfig = { - profile: options.profile, + profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env), }; const configuredRegion = getConfiguredBedrockRegion(options); - const hasConfiguredProfile = hasConfiguredBedrockProfile(); + const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE")); const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl); const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint( model.baseUrl, configuredRegion, - hasConfiguredProfile, + hasAmbientConfiguredProfile, ); - // Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured. + // Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured. // This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in // catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE. if (useExplicitEndpoint) { @@ -134,37 +144,50 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt } // Resolve bearer token for Bedrock API key auth. - const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined; - const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1"; + const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1"; + const bearerToken = + options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined; + const useBearerToken = bearerToken !== undefined && !skipAuth; // in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - // Region resolution: explicit option > env vars > SDK default chain. - // When AWS_PROFILE is set, we leave region undefined so the SDK can - // resovle it from aws profile configs. Otherwise fall back to us-east-1. - if (configuredRegion) { + // Region resolution: ARN-embedded > explicit option > env vars > SDK default chain. + // When the model ID is an inference profile ARN, extract the region from it. + // This avoids conflicts with AWS_REGION set for other services. + const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/); + if (arnRegionMatch) { + config.region = arnRegionMatch[1]; + } else if (configuredRegion) { config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; - } else if (!hasConfiguredProfile) { + } else if (!hasAmbientConfiguredProfile) { config.region = "us-east-1"; } // Support proxies that don't need authentication - if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") { + if (skipAuth) { config.credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key", }; } - const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl); - if (proxyAgents) { + const credentials = getConfiguredBedrockCredentials(options.env); + if (!skipAuth && credentials) { + config.credentials = credentials; + } + + const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env); + if (proxyUrl) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. // Use NodeHttpHandler to support HTTP(S) proxy agents. - config.requestHandler = new NodeHttpHandler(proxyAgents); - } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { + config.requestHandler = new NodeHttpHandler({ + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, + }); + } else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") { // Some custom endpoints require HTTP/1.1 instead of HTTP/2 config.requestHandler = new NodeHttpHandler(); } @@ -182,12 +205,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt try { const client = new BedrockRuntimeClient(config); - const cacheRetention = resolveCacheRetention(options.cacheRetention); + if (options.headers && Object.keys(options.headers).length > 0) { + addCustomHeadersMiddleware(client, options.headers); + } + const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env); const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined); let commandInput = { modelId: model.id, - messages: convertMessages(context, model, cacheRetention), - system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), + messages: convertMessages(context, model, cacheRetention, options.env), + system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env), inferenceConfig: { ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), @@ -280,6 +306,13 @@ const BEDROCK_ERROR_PREFIXES: Record = { ServiceUnavailableException: "Service unavailable", }; +/** + * Some models reject the account/profile's configured Bedrock data retention mode + * (e.g. "data retention mode 'default' is not available for this model"). Point + * users at the AWS docs explaining how to configure a supported mode. + */ +const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html"; + /** * Format a Bedrock error with a human-readable prefix. * AWS SDK exceptions (both from `client.send()` and from stream event items) @@ -289,11 +322,50 @@ const BEDROCK_ERROR_PREFIXES: Record = { */ function formatBedrockError(error: unknown): string { const message = error instanceof Error ? error.message : JSON.stringify(error); + const dataRetentionHint = /data retention mode/i.test(message) + ? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.` + : ""; if (error instanceof BedrockRuntimeServiceException) { const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; - return `${prefix}: ${message}`; + return `${prefix}: ${message}${dataRetentionHint}`; } - return message; + return `${message}${dataRetentionHint}`; +} + +/** + * Header keys that must never be overwritten by caller-supplied headers. + * `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization` + * is owned by SigV4 or the bearer-token path (config.token + authSchemePreference). + * Compared case-insensitively (caller key is lower-cased before lookup). + */ +const RESERVED_HEADER_EXACT = new Set(["authorization", "host"]); + +function isReservedHeader(key: string): boolean { + const lower = key.toLowerCase(); + return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower); +} + +/** + * Attach caller-supplied headers to the outgoing Bedrock request via a Smithy + * `build`-step middleware. The `build` step runs after request serialisation but + * before SigV4 signing, so injected headers are covered by the signature. Reserved + * SigV4 / auth headers (`x-amz-*`, `authorization`, `host`) are silently skipped; + * all other caller headers override any existing same-named header on the request. + */ +function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Record): void { + const middleware: BuildMiddleware = (next) => async (args) => { + const request = args.request; + if (request && typeof request === "object" && "headers" in request) { + const requestHeaders = (request as { headers: Record }).headers; + for (const [key, value] of Object.entries(headers)) { + if (!isReservedHeader(key)) { + requestHeaders[key] = value; + } + } + } + return next(args); + }; + client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" }); } export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( @@ -342,6 +414,14 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp } satisfies BedrockOptions); }; +export function register(): void { + registerApiProvider({ + api: "bedrock-converse-stream", + stream: streamBedrock, + streamSimple: streamSimpleBedrock, + }); +} + function handleContentBlockStart( event: ContentBlockStartEvent, blocks: Block[], @@ -481,12 +561,19 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[] function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean { const candidates = getModelMatchCandidates(modelId, modelName); - return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6")); + return candidates.some( + (s) => + s.includes("opus-4-6") || + s.includes("opus-4-7") || + s.includes("opus-4-8") || + s.includes("sonnet-4-6") || + s.includes("fable-5"), + ); } function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const candidates = getModelMatchCandidates(model.id, model.name); - return candidates.some((s) => s.includes("opus-4-7")); + return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5")); } function mapThinkingLevelToEffort( @@ -515,11 +602,11 @@ function mapThinkingLevelToEffort( * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -554,14 +641,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea * As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. * Amazon Nova models have automatic caching and don't need explicit cache points. */ -function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean { +function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean { const candidates = getModelMatchCandidates(model.id, model.name); const hasClaudeRef = candidates.some((s) => s.includes("claude")); if (!hasClaudeRef) { // Application inference profiles don't contain the model name in the ARN. // Allow users to force cache points via environment variable. - if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true; + if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true; return false; } // Claude 4.x models (opus-4, sonnet-4, haiku-4) @@ -589,13 +676,14 @@ function buildSystemPrompt( systemPrompt: string | undefined, model: Model<"bedrock-converse-stream">, cacheRetention: CacheRetention, + env?: ProviderEnv, ): SystemContentBlock[] | undefined { if (!systemPrompt) return undefined; const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }]; // Add cache point for supported Claude models when caching is enabled - if (cacheRetention !== "none" && supportsPromptCaching(model)) { + if (cacheRetention !== "none" && supportsPromptCaching(model, env)) { blocks.push({ cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) }, }); @@ -609,10 +697,34 @@ function normalizeToolCallId(id: string): string { return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; } +function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined { + const sanitized = sanitizeSurrogates(text); + return sanitized.trim().length === 0 ? undefined : { text: sanitized }; +} + +function createRequiredTextBlock(text: string): ContentBlock.TextMember { + return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER }; +} + +function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] { + const result: ToolResultContentBlock[] = []; + for (const c of content) { + if (c.type === "image") { + result.push({ image: createImageBlock(c.mimeType, c.data) }); + } else { + const textBlock = createNonBlankTextBlock(c.text); + if (textBlock) result.push(textBlock); + } + } + if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER }); + return result; +} + function convertMessages( context: Context, model: Model<"bedrock-converse-stream">, cacheRetention: CacheRetention, + env?: ProviderEnv, ): Message[] { const result: Message[] = []; const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); @@ -624,13 +736,15 @@ function convertMessages( case "user": { const content: ContentBlock[] = []; if (typeof m.content === "string") { - content.push({ text: sanitizeSurrogates(m.content) }); + content.push(createRequiredTextBlock(m.content)); } else { for (const c of m.content) { switch (c.type) { - case "text": - content.push({ text: sanitizeSurrogates(c.text) }); + case "text": { + const textBlock = createNonBlankTextBlock(c.text); + if (textBlock) content.push(textBlock); break; + } case "image": content.push({ image: createImageBlock(c.mimeType, c.data) }); break; @@ -638,8 +752,8 @@ function convertMessages( continue; } } + if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER }); } - if (content.length === 0) continue; result.push({ role: ConversationRole.USER, content, @@ -655,19 +769,22 @@ function convertMessages( const contentBlocks: ContentBlock[] = []; for (const c of m.content) { switch (c.type) { - case "text": + case "text": { // Skip empty text blocks - if (c.text.trim().length === 0) continue; - contentBlocks.push({ text: sanitizeSurrogates(c.text) }); + const textBlock = createNonBlankTextBlock(c.text); + if (!textBlock) continue; + contentBlocks.push(textBlock); break; + } case "toolCall": contentBlocks.push({ toolUse: { toolUseId: c.id, name: c.name, input: c.arguments }, }); break; - case "thinking": + case "thinking": { // Skip empty thinking blocks - if (c.thinking.trim().length === 0) continue; + const thinking = sanitizeSurrogates(c.thinking); + if (thinking.trim().length === 0) continue; // Only Anthropic models support the signature field in reasoningText. // For other models, we omit the signature to avoid errors like: // "This model doesn't support the reasoningContent.reasoningText.signature field" @@ -676,12 +793,12 @@ function convertMessages( // persisted message lacks a signature, Bedrock rejects the replayed // reasoning block. Fall back to plain text, matching Anthropic. if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) { - contentBlocks.push({ text: sanitizeSurrogates(c.thinking) }); + contentBlocks.push({ text: thinking }); } else { contentBlocks.push({ reasoningContent: { reasoningText: { - text: sanitizeSurrogates(c.thinking), + text: thinking, signature: c.thinkingSignature, }, }, @@ -690,11 +807,12 @@ function convertMessages( } else { contentBlocks.push({ reasoningContent: { - reasoningText: { text: sanitizeSurrogates(c.thinking) }, + reasoningText: { text: thinking }, }, }); } break; + } default: continue; } @@ -718,11 +836,7 @@ function convertMessages( toolResults.push({ toolResult: { toolUseId: m.toolCallId, - content: m.content.map((c) => - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ), + content: convertToolResultContent(m.content), status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }); @@ -734,11 +848,7 @@ function convertMessages( toolResults.push({ toolResult: { toolUseId: nextMsg.toolCallId, - content: nextMsg.content.map((c) => - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ), + content: convertToolResultContent(nextMsg.content), status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }); @@ -760,7 +870,7 @@ function convertMessages( } // Add cache point to the last user message for supported Claude models when caching is enabled - if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) { + if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) { const lastMessage = result[result.length - 1]; if (lastMessage.role === ConversationRole.USER && lastMessage.content) { (lastMessage.content as ContentBlock[]).push({ @@ -822,19 +932,26 @@ function mapStopReason(reason: string | undefined): StopReason { } function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined { - if (typeof process === "undefined") { - return options.region; - } - - return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined; + return ( + options.region || + getProviderEnvValue("AWS_REGION", options.env) || + getProviderEnvValue("AWS_DEFAULT_REGION", options.env) || + undefined + ); } -function hasConfiguredBedrockProfile(): boolean { - if (typeof process === "undefined") { - return false; +function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined { + const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env); + const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env); + if (!accessKeyId || !secretAccessKey) { + return undefined; } - - return Boolean(process.env.AWS_PROFILE); + const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env); + return { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }; } function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined { @@ -854,14 +971,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | function shouldUseExplicitBedrockEndpoint( baseUrl: string, configuredRegion: string | undefined, - hasConfiguredProfile: boolean, + hasAmbientConfiguredProfile: boolean, ): boolean { const endpointRegion = getStandardBedrockEndpointRegion(baseUrl); if (!endpointRegion) { return true; } - return !configuredRegion && !hasConfiguredProfile; + return !configuredRegion && !hasAmbientConfiguredProfile; } function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a594a479..4c385787 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -5,8 +5,9 @@ import type { MessageCreateParamsStreaming, MessageParam, RawMessageStreamEvent, + RefusalStopDetails, } from "@anthropic-ai/sdk/resources/messages.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, @@ -17,6 +18,7 @@ import type { ImageContent, Message, Model, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -30,6 +32,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { resolveCloudflareBaseUrl } from "./cloudflare.ts"; @@ -41,11 +44,11 @@ import { transformMessages } from "./transform-messages.ts"; * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -54,8 +57,9 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention function getCacheControl( model: Model<"anthropic-messages">, cacheRetention?: CacheRetention, + env?: ProviderEnv, ): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } { - const retention = resolveCacheRetention(cacheRetention); + const retention = resolveCacheRetention(cacheRetention, env); if (retention === "none") { return { retention }; } @@ -164,7 +168,9 @@ export type AnthropicThinkingDisplay = "summarized" | "omitted"; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; -function getAnthropicCompat(model: Model<"anthropic-messages">): Required { +function getAnthropicCompat( + model: Model<"anthropic-messages">, +): Required> { // Auto-detect session affinity and cache control support from provider const isFireworks = model.provider === "fireworks"; const isCloudflareAiGatewayAnthropic = @@ -175,35 +181,42 @@ function getAnthropicCompat(model: Model<"anthropic-messages">): Required | undefined; if (model.provider === "github-copilot") { @@ -470,7 +498,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }); } - const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const created = createClient( @@ -481,6 +509,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti options?.headers, copilotDynamicHeaders, cacheSessionId, + options?.env, ); client = created.client; isOAuth = created.isOAuthToken; @@ -493,7 +522,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); @@ -511,6 +540,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0; // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; @@ -637,7 +667,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti } } else if (event.type === "message_delta") { if (event.delta.stop_reason) { - output.stopReason = mapStopReason(event.delta.stop_reason); + const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details); + output.stopReason = stopReasonResult.stopReason; + if (stopReasonResult.errorMessage) { + output.errorMessage = stopReasonResult.errorMessage; + } } // Only update usage fields if present (not null). // Preserves input_tokens from message_start when proxies omit it in message_delta. @@ -665,7 +699,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + throw new Error(output.errorMessage || "An unknown error occurred"); } stream.push({ type: "done", reason: output.stopReason, message: output }); @@ -686,24 +720,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti return stream; }; -/** - * Check if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6) - */ -function supportsAdaptiveThinking(modelId: string): boolean { - // Adaptive-thinking model IDs (with or without date suffix) - return ( - modelId.includes("opus-4-6") || - modelId.includes("opus-4.6") || - modelId.includes("opus-4-7") || - modelId.includes("opus-4.7") || - modelId.includes("sonnet-4-6") || - modelId.includes("sonnet-4.6") - ); -} - /** * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. - * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7+ and Fable 5 support "xhigh". */ function mapThinkingLevelToEffort( model: Model<"anthropic-messages">, @@ -730,7 +749,7 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -740,9 +759,9 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS return streamAnthropic(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions); } - // For Opus 4.6 and Sonnet 4.6: use adaptive thinking with effort level - // For older models: use budget-based thinking - if (supportsAdaptiveThinking(model.id)) { + // For models with adaptive thinking: use an effort level. + // For older models: use budget-based thinking. + if (model.compat?.forceAdaptiveThinking === true) { const effort = mapThinkingLevelToEffort(model, options.reasoning); return streamAnthropic(model, context, { ...base, @@ -768,6 +787,14 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS } satisfies AnthropicOptions); }; +export function register(): void { + registerApiProvider({ + api: "anthropic-messages", + stream: streamAnthropic, + streamSimple: streamSimpleAnthropic, + }); +} + function isOAuthToken(apiKey: string): boolean { return apiKey.includes("sk-ant-oat"); } @@ -780,10 +807,10 @@ function createClient( optionsHeaders?: Record, dynamicHeaders?: Record, sessionId?: string, + env?: ProviderEnv, ): { client: Anthropic; isOAuthToken: boolean } { - // Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in. - // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. - const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id); + // Adaptive thinking models have interleaved thinking built in, so skip the beta header. + const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true; const betaFeatures: string[] = []; if (useFineGrainedToolStreamingBeta) { betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); @@ -796,7 +823,7 @@ function createClient( const client = new Anthropic({ apiKey: null, authToken: null, - baseURL: resolveCloudflareBaseUrl(model), + baseURL: resolveCloudflareBaseUrl(model, env), dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( { @@ -889,10 +916,11 @@ function buildParams( isOAuthToken: boolean, options?: AnthropicOptions, ): MessageCreateParamsStreaming { - const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); + const compat = getAnthropicCompat(model); const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), + messages: convertMessages(context.messages, model, isOAuthToken, cacheControl, compat.allowEmptySignature), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -924,13 +952,12 @@ function buildParams( ]; } - // Temperature is incompatible with extended thinking (adaptive or budget-based). - if (options?.temperature !== undefined && !options?.thinkingEnabled) { + // Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+. + if (options?.temperature !== undefined && !options?.thinkingEnabled && compat.supportsTemperature) { params.temperature = options.temperature; } if (context.tools && context.tools.length > 0) { - const compat = getAnthropicCompat(model); params.tools = convertTools( context.tools, isOAuthToken, @@ -939,14 +966,13 @@ function buildParams( ); } - // Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6), - // budget-based (older models), or explicitly disabled. + // Configure thinking mode: adaptive, budget-based, or explicitly disabled. if (model.reasoning) { if (options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7 and Mythos Preview behave like // older Claude 4 models (whose API default is also "summarized"). const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; - if (supportsAdaptiveThinking(model.id)) { + if (model.compat?.forceAdaptiveThinking === true) { // Adaptive thinking: Claude decides when and how much to think. params.thinking = { type: "adaptive", display }; if (options.effort) { @@ -966,7 +992,7 @@ function buildParams( display, }; } - } else if (options?.thinkingEnabled === false) { + } else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) { params.thinking = { type: "disabled" }; } } @@ -999,6 +1025,7 @@ function convertMessages( model: Model<"anthropic-messages">, isOAuthToken: boolean, cacheControl?: CacheControlEphemeral, + allowEmptySignature = false, ): MessageParam[] { const params: MessageParam[] = []; @@ -1067,13 +1094,21 @@ function convertMessages( } if (block.thinking.trim().length === 0) continue; // If thinking signature is missing/empty (e.g., from aborted stream), - // convert to plain text block without tags to avoid API rejection - // and prevent Claude from mimicking the tags in responses + // convert to plain text for Anthropic. Some compatible providers emit + // and accept empty signatures, so let marked models preserve the block. if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { - blocks.push({ - type: "text", - text: sanitizeSurrogates(block.thinking), - }); + blocks.push( + allowEmptySignature + ? { + type: "thinking", + thinking: sanitizeSurrogates(block.thinking), + signature: "", + } + : { + type: "text", + text: sanitizeSurrogates(block.thinking), + }, + ); } else { blocks.push({ type: "thinking", @@ -1187,22 +1222,28 @@ function convertTools( }); } -function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason { +function mapStopReason( + reason: Anthropic.Messages.StopReason | string, + stopDetails?: RefusalStopDetails | null, +): { stopReason: StopReason; errorMessage?: string } { switch (reason) { case "end_turn": - return "stop"; + return { stopReason: "stop" }; case "max_tokens": - return "length"; + return { stopReason: "length" }; case "tool_use": - return "toolUse"; + return { stopReason: "toolUse" }; case "refusal": - return "error"; + return { + stopReason: "error", + errorMessage: stopDetails?.explanation || `The model refused to complete the request`, + }; case "pause_turn": // Stop is good enough -> resubmit - return "stop"; + return { stopReason: "stop" }; case "stop_sequence": - return "stop"; // We don't supply stop sequences, so this should never happen + return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen case "sensitive": // Content flagged by safety filters (not yet in SDK types) - return "error"; + return { stopReason: "error" }; default: // Handle unknown stop reasons gracefully (API may add new values) throw new Error(`Unhandled stop reason: ${reason}`); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 60601a38..785de0f2 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -1,6 +1,6 @@ import { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { clampThinkingLevel } from "../models.ts"; import type { Api, @@ -13,6 +13,7 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -37,7 +38,9 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: if (options?.azureDeploymentName) { return options.azureDeploymentName; } - const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id); + const mappedDeployment = parseDeploymentNameMap( + getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env), + ).get(model.id); return mappedDeployment || model.id; } @@ -101,7 +104,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" try { // Create Azure OpenAI client - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const client = createClient(model, apiKey, options); let params = buildParams(model, context, options, deploymentName); const nextParams = await options?.onPayload?.(params, model); @@ -111,7 +117,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); @@ -150,7 +156,7 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -165,6 +171,14 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp } satisfies AzureOpenAIResponsesOptions); }; +export function register(): void { + registerApiProvider({ + api: "azure-openai-responses", + stream: streamAzureOpenAIResponses, + streamSimple: streamSimpleAzureOpenAIResponses, + }); +} + function normalizeAzureBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/, ""); let url: URL; @@ -196,10 +210,14 @@ function resolveAzureConfig( model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions, ): { baseUrl: string; apiVersion: string } { - const apiVersion = options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION; + const apiVersion = + options?.azureApiVersion || + getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) || + DEFAULT_AZURE_API_VERSION; - const baseUrl = options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined; - const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME; + const baseUrl = + options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined; + const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env); let resolvedBaseUrl = baseUrl; @@ -224,15 +242,6 @@ function resolveAzureConfig( } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { - if (!apiKey) { - if (!process.env.AZURE_OPENAI_API_KEY) { - throw new Error( - "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.AZURE_OPENAI_API_KEY; - } - const headers = { ...model.headers }; if (options?.headers) { @@ -263,6 +272,7 @@ function buildParams( input: messages, stream: true, prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), + store: false, }; if (options?.maxTokens) { diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts index cd0a8159..98546419 100644 --- a/packages/ai/src/providers/cloudflare.ts +++ b/packages/ai/src/providers/cloudflare.ts @@ -1,4 +1,5 @@ -import type { Api, Model } from "../types.ts"; +import type { Api, Model, ProviderEnv } from "../types.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; /** Workers AI direct endpoint. */ export const CLOUDFLARE_WORKERS_AI_BASE_URL = @@ -20,12 +21,12 @@ export function isCloudflareProvider(provider: string): boolean { return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway"; } -/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */ -export function resolveCloudflareBaseUrl(model: Model): string { +/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from provider env or process.env. */ +export function resolveCloudflareBaseUrl(model: Model, env?: ProviderEnv): string { const url = model.baseUrl; if (!url.includes("{")) return url; const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => { - const value = process.env[name]; + const value = getProviderEnvValue(name, env); if (!value) { throw new Error(`${name} is required for provider ${model.provider} but is not set.`); } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index e5f18dab..224e49fd 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -7,6 +7,7 @@ import { type ThinkingConfig, ThinkingLevel, } from "@google/genai"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, @@ -14,6 +15,7 @@ import type { Context, Model, ThinkingLevel as PiThinkingLevel, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, @@ -23,6 +25,7 @@ import type { ToolCall, } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleThinkingLevel } from "./google-shared.ts"; import { @@ -91,7 +94,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOpt // Create the client using either a Vertex API key, if provided, or ADC with project and location const client = apiKey ? createClientWithApiKey(model, apiKey, options?.headers) - : createClient(model, resolveProject(options), resolveLocation(options), options?.headers); + : createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -328,17 +331,28 @@ export const streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStr } satisfies GoogleVertexOptions); }; +export function register(): void { + registerApiProvider({ + api: "google-vertex", + stream: streamGoogleVertex, + streamSimple: streamSimpleGoogleVertex, + }); +} + function createClient( model: Model<"google-vertex">, project: string, location: string, optionsHeaders?: Record, + env?: ProviderEnv, ): GoogleGenAI { + const googleAuthOptions = buildGoogleAuthOptions(env); return new GoogleGenAI({ vertexai: true, project, location, apiVersion: API_VERSION, + ...(googleAuthOptions ? { googleAuthOptions } : {}), httpOptions: buildHttpOptions(model, optionsHeaders), }); } @@ -394,8 +408,13 @@ function baseUrlIncludesApiVersion(baseUrl: string): boolean { } } +function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined { + const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env); + return keyFilename ? { keyFilename } : undefined; +} + function resolveApiKey(options?: GoogleVertexOptions): string | undefined { - const apiKey = options?.apiKey?.trim() || process.env.GOOGLE_CLOUD_API_KEY?.trim(); + const apiKey = options?.apiKey?.trim(); if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) { return undefined; } @@ -407,7 +426,10 @@ function isPlaceholderApiKey(apiKey: string): boolean { } function resolveProject(options?: GoogleVertexOptions): string { - const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; + const project = + options?.project || + getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) || + getProviderEnvValue("GCLOUD_PROJECT", options?.env); if (!project) { throw new Error( "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.", @@ -417,7 +439,7 @@ function resolveProject(options?: GoogleVertexOptions): string { } function resolveLocation(options?: GoogleVertexOptions): string { - const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION; + const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env); if (!location) { throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options."); } @@ -490,7 +512,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { } function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); + const id = model.id.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig { diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index d0ddf673..62ad9cfd 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -4,7 +4,7 @@ import { GoogleGenAI, type ThinkingConfig, } from "@google/genai"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, @@ -72,7 +72,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const client = createClient(model, apiKey, options?.headers); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); @@ -280,7 +283,7 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -313,6 +316,14 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt } satisfies GoogleOptions); }; +export function register(): void { + registerApiProvider({ + api: "google-generative-ai", + stream: streamGoogle, + streamSimple: streamSimpleGoogle, + }); +} + function createClient( model: Model<"google-generative-ai">, apiKey?: string, @@ -404,7 +415,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { } function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); + const id = model.id.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig { diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/providers/images/openrouter.ts index 01e81a60..d1b2c7e8 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/providers/images/openrouter.ts @@ -6,7 +6,7 @@ import type { ChatCompletionContentPartText, ChatCompletionCreateParamsNonStreaming, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../../env-api-keys.ts"; +import { registerImagesApiProvider } from "../../images-api-registry.ts"; import type { AssistantImages, ImageContent, @@ -50,9 +50,9 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { - throw new Error(`No API key available for provider: ${model.provider}`); + throw new Error(`No API key for provider: ${model.provider}`); } const client = createClient(model, apiKey, options?.headers); let params = buildParams(model, context); @@ -63,7 +63,7 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: response, response: rawResponse } = await client.chat.completions .create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions) @@ -104,6 +104,13 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image } }; +export function register(): void { + registerImagesApiProvider({ + api: "openrouter-images", + generateImages: generateImagesOpenRouter, + }); +} + function createClient( model: ImagesModel<"openrouter-images">, apiKey: string, diff --git a/packages/ai/src/providers/images/register-builtins.ts b/packages/ai/src/providers/images/register-builtins.ts deleted file mode 100644 index e3decbb9..00000000 --- a/packages/ai/src/providers/images/register-builtins.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { registerImagesApiProvider } from "../../images-api-registry.ts"; -import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts"; -import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts"; - -interface OpenRouterImagesProviderModule { - generateImagesOpenRouter: typeof generateImagesOpenRouterFunction; -} - -let openRouterImagesProviderModulePromise: Promise | undefined; - -function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages { - return { - api: model.api, - provider: model.provider, - model: model.id, - output: [], - stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - }; -} - -function loadOpenRouterImagesProviderModule(): Promise { - openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then( - (module) => module as OpenRouterImagesProviderModule, - ); - return openRouterImagesProviderModulePromise; -} - -export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", ImagesOptions> = async ( - model: ImagesModel<"openrouter-images">, - context: ImagesContext, - options?: ImagesOptions, -) => { - try { - const module = await loadOpenRouterImagesProviderModule(); - return await module.generateImagesOpenRouter(model, context, options); - } catch (error) { - return createLazyLoadErrorImages(model, error); - } -}; - -export function registerBuiltInImagesApiProviders(): void { - registerImagesApiProvider({ - api: "openrouter-images", - generateImages: generateImagesOpenRouter, - }); -} - -registerBuiltInImagesApiProviders(); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index a15f141a..ba331af4 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -6,7 +6,7 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -57,7 +57,7 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio const output = createOutput(model); try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -113,7 +113,7 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -131,6 +131,14 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple } satisfies MistralOptions); }; +export function register(): void { + registerApiProvider({ + api: "mistral-conversations", + stream: streamMistral, + streamSimple: streamSimpleMistral, + }); +} + function createOutput(model: Model<"mistral-conversations">): AssistantMessage { return { role: "assistant", @@ -227,7 +235,7 @@ function buildRequestOptions(model: Model<"mistral-conversations">, options?: Mi // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). // Respect explicit caller-provided header values. - if (options?.sessionId && !headers["x-affinity"]) { + if (shouldUsePromptCaching(options) && !headers["x-affinity"]) { headers["x-affinity"] = options.sessionId; } @@ -256,6 +264,7 @@ function buildChatPayload( if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice); if (options?.promptMode) payload.promptMode = options.promptMode; if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort; + if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId; if (context.systemPrompt) { payload.messages.unshift({ @@ -267,6 +276,31 @@ function buildChatPayload( return payload; } +function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } { + return options?.cacheRetention !== "none" && !!options?.sessionId; +} + +function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number { + const rawUsage = usage as { + promptTokensDetails?: { cachedTokens?: unknown } | null; + prompt_tokens_details?: { cached_tokens?: unknown } | null; + promptTokenDetails?: { cachedTokens?: unknown } | null; + prompt_token_details?: { cached_tokens?: unknown } | null; + numCachedTokens?: unknown; + num_cached_tokens?: unknown; + }; + const rawCachedTokens = + rawUsage.promptTokensDetails?.cachedTokens ?? + rawUsage.prompt_tokens_details?.cached_tokens ?? + rawUsage.promptTokenDetails?.cachedTokens ?? + rawUsage.prompt_token_details?.cached_tokens ?? + rawUsage.numCachedTokens ?? + rawUsage.num_cached_tokens ?? + 0; + const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0; + return Math.min(promptTokens, Math.max(0, cachedTokens)); +} + async function consumeChatStream( model: Model<"mistral-conversations">, output: AssistantMessage, @@ -306,11 +340,16 @@ async function consumeChatStream( output.responseId ||= chunk.id; if (chunk.usage) { - output.usage.input = chunk.usage.promptTokens || 0; + const promptTokens = chunk.usage.promptTokens || 0; + const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens); + + output.usage.input = Math.max(0, promptTokens - cachedPromptTokens); output.usage.output = chunk.usage.completionTokens || 0; - output.usage.cacheRead = 0; + output.usage.cacheRead = cachedPromptTokens; output.usage.cacheWrite = 0; - output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output; + output.usage.totalTokens = + chunk.usage.totalTokens || + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 70df0810..1bc2879a 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -20,7 +20,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -28,11 +28,13 @@ import type { AssistantMessage, Context, Model, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, Usage, } from "../types.ts"; +import { combineAbortSignals } from "../utils/abort-signals.ts"; import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic, @@ -40,6 +42,7 @@ import { } from "../utils/diagnostics.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -50,8 +53,13 @@ import { buildBaseOptions } from "./simple-options.ts"; const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; -const MAX_RETRIES = 3; +const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; +const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; +// Keep a bounded pre-header timeout so zero-event Codex SSE stalls fail instead of +// leaving callers stuck on "Working..." indefinitely. See #4945. +const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000; +const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -100,13 +108,54 @@ interface RequestBody { // Retry Helpers // ============================================================================ +function isTerminalRateLimitError(errorText: string): boolean { + return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test( + errorText, + ); +} + function isRetryableError(status: number, errorText: string): boolean { + if (status === 429 && isTerminalRateLimitError(errorText)) { + return false; + } if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { return true; } return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText); } +function getRetryAfterDelayMs(headers: Headers): number | undefined { + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs !== null) { + const millis = Number(retryAfterMs); + if (Number.isFinite(millis)) { + return Math.max(0, millis); + } + } + + const retryAfter = headers.get("retry-after"); + if (!retryAfter) { + return undefined; + } + + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()); + } + + return undefined; +} + +function capRetryDelayMs(delayMs: number, options?: StreamOptions): number { + const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS; + return maxRetryDelayMs > 0 ? Math.min(delayMs, maxRetryDelayMs) : delayMs; +} + function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -121,6 +170,28 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } +function normalizeTimeoutMs(value: number | undefined): number | undefined { + if (value === undefined) return undefined; + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid timeoutMs: ${String(value)}`); + } + return Math.floor(value); +} + +function createSSEHeaderTimeout(): { signal: AbortSignal; clear: () => void; error: () => Error | undefined } { + const controller = new AbortController(); + let error: Error | undefined; + const timeout = setTimeout(() => { + error = new Error(`Codex SSE response headers timed out after ${DEFAULT_SSE_HEADER_TIMEOUT_MS}ms`); + controller.abort(error); + }, DEFAULT_SSE_HEADER_TIMEOUT_MS); + return { + signal: controller.signal, + clear: () => clearTimeout(timeout), + error: () => error, + }; +} + // ============================================================================ // Main Stream Function // ============================================================================ @@ -152,7 +223,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -173,6 +244,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" websocketRequestId, ); const bodyJson = JSON.stringify(body); + const idleTimeoutMs = normalizeTimeoutMs(options?.timeoutMs); + const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs); const transport = options?.transport || "auto"; const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); if (websocketDisabledForSession) { @@ -192,6 +265,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" () => { websocketStarted = true; }, + idleTimeoutMs, + websocketConnectTimeoutMs, options, ); @@ -231,19 +306,30 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" // Fetch with retry logic for rate limits and transient errors let response: Response | undefined; let lastError: Error | undefined; + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } try { - response = await fetch(resolveCodexUrl(model.baseUrl), { - method: "POST", - headers: sseHeaders, - body: bodyJson, - signal: options?.signal, - }); + const headerTimeout = createSSEHeaderTimeout(); + const combinedSignal = combineAbortSignals([options?.signal, headerTimeout.signal]); + try { + response = await fetch(resolveCodexUrl(model.baseUrl), { + method: "POST", + headers: sseHeaders, + body: bodyJson, + signal: combinedSignal.signal, + }); + } catch (error) { + const timeoutError = headerTimeout.error(); + throw timeoutError && !options?.signal?.aborted ? timeoutError : error; + } finally { + combinedSignal.cleanup(); + headerTimeout.clear(); + } await options?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, model, @@ -254,29 +340,14 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } const errorText = await response.text(); - if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - let delayMs = BASE_DELAY_MS * 2 ** attempt; - - const retryAfterMs = response.headers.get("retry-after-ms"); - if (retryAfterMs !== null) { - const millis = Number(retryAfterMs); - if (Number.isFinite(millis)) { - delayMs = Math.max(0, millis); - } - } else { - const retryAfter = response.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) { - delayMs = Math.max(0, seconds * 1000); - } else { - const date = Date.parse(retryAfter); - if (!Number.isNaN(date)) { - delayMs = Math.max(0, date - Date.now()); - } - } - } - } + if (attempt < maxRetries && isRetryableError(response.status, errorText)) { + const retryAfterDelayMs = getRetryAfterDelayMs(response.headers); + const delayMs = + retryAfterDelayMs === undefined + ? BASE_DELAY_MS * 2 ** attempt + : response.status === 429 + ? capRetryDelayMs(retryAfterDelayMs, options) + : retryAfterDelayMs; await sleep(delayMs, options?.signal); continue; @@ -297,7 +368,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } lastError = error instanceof Error ? error : new Error(String(error)); // Network errors are retryable - if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) { + if (attempt < maxRetries && !lastError.message.includes("usage limit")) { const delayMs = BASE_DELAY_MS * 2 ** attempt; await sleep(delayMs, options?.signal); continue; @@ -343,7 +414,7 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -358,6 +429,14 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp } satisfies OpenAICodexResponsesOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-codex-responses", + stream: streamOpenAICodexResponses, + streamSimple: streamSimpleOpenAICodexResponses, + }); +} + // ============================================================================ // Request Building // ============================================================================ @@ -477,7 +556,7 @@ async function processStream( model: Model<"openai-codex-responses">, options?: OpenAICodexResponsesOptions, ): Promise { - await processResponsesStream(mapCodexEvents(parseSSE(response)), output, stream, model, { + await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { serviceTier: options?.serviceTier, resolveServiceTier: resolveCodexServiceTier, applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), @@ -555,16 +634,26 @@ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined // SSE Parsing // ============================================================================ -async function* parseSSE(response: Response): AsyncGenerator> { +async function* parseSSE(response: Response, signal?: AbortSignal): AsyncGenerator> { if (!response.body) return; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + const onAbort = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener("abort", onAbort, { once: true }); try { while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } const { done, value } = await reader.read(); + if (signal?.aborted) { + throw new Error("Request was aborted"); + } if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -594,6 +683,7 @@ async function* parseSSE(response: Response): AsyncGenerator WebSocketLike; let _cachedWebsocket: WebSocketConstructor | null = null; -async function getWebSocketConstructor(): Promise { - if (_cachedWebsocket) return _cachedWebsocket; +async function getWebSocketConstructor(env?: ProviderEnv): Promise { + if (!env && _cachedWebsocket) return _cachedWebsocket; // bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489 // TODO: remove this when bun supports proxy envs in websocket. - if ( - process?.versions?.bun && - (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) - ) { - const m = await dynamicImport("proxy-from-env"); - const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl; - - _cachedWebsocket = class extends WebSocket { + if (typeof process !== "undefined" && process.versions?.bun) { + const WebSocketWithProxy = class extends WebSocket { constructor(url: string | URL, options?: string | string[] | Record) { let _opts: Record = {}; if (Array.isArray(options) || typeof options === "string") { @@ -756,11 +840,17 @@ async function getWebSocketConstructor(): Promise { _opts = { ...options }; } - const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:")); - super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any); + const proxyUrl = resolveHttpProxyUrlForTarget( + url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"), + env, + ); + super(url, { ..._opts, ...(proxyUrl ? { proxy: proxyUrl.toString() } : {}) } as any); } }; - return _cachedWebsocket; + if (!env) { + _cachedWebsocket = WebSocketWithProxy; + } + return WebSocketWithProxy; } const ctor = (globalThis as { WebSocket?: unknown }).WebSocket; @@ -810,8 +900,14 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke }, SESSION_WEBSOCKET_CACHE_TTL_MS); } -async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise { - const WebSocketCtor = await getWebSocketConstructor(); +async function connectWebSocket( + url: string, + headers: Headers, + signal?: AbortSignal, + connectTimeoutMs = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS, + env?: ProviderEnv, +): Promise { + const WebSocketCtor = await getWebSocketConstructor(env); if (!WebSocketCtor) { throw new Error("WebSocket transport is not available in this runtime"); } @@ -821,6 +917,7 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig return new Promise((resolve, reject) => { let settled = false; + let timeout: ReturnType | undefined; let socket: WebSocketLike; try { @@ -830,6 +927,25 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig return; } + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + }; + const fail = (error: Error, closeReason?: string) => { + if (settled) return; + settled = true; + cleanup(); + if (closeReason) { + closeWebSocketSilently(socket, 1000, closeReason); + } + reject(error); + }; const onOpen: WebSocketListener = () => { if (settled) return; settled = true; @@ -837,38 +953,28 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig resolve(socket); }; const onError: WebSocketListener = (event) => { - const error = extractWebSocketError(event); - if (settled) return; - settled = true; - cleanup(); - reject(error); + fail(extractWebSocketError(event)); }; const onClose: WebSocketListener = (event) => { - const error = extractWebSocketCloseError(event); - if (settled) return; - settled = true; - cleanup(); - reject(error); + fail(extractWebSocketCloseError(event)); }; const onAbort = () => { - if (settled) return; - settled = true; - cleanup(); - socket.close(1000, "aborted"); - reject(new Error("Request was aborted")); - }; - - const cleanup = () => { - socket.removeEventListener("open", onOpen); - socket.removeEventListener("error", onError); - socket.removeEventListener("close", onClose); - signal?.removeEventListener("abort", onAbort); + fail(new Error("Request was aborted"), "aborted"); }; socket.addEventListener("open", onOpen); socket.addEventListener("error", onError); socket.addEventListener("close", onClose); signal?.addEventListener("abort", onAbort); + + if (connectTimeoutMs > 0) { + timeout = setTimeout(() => { + fail(new Error(`WebSocket connect timeout after ${connectTimeoutMs}ms`), "connect_timeout"); + }, connectTimeoutMs); + } + if (signal?.aborted) { + onAbort(); + } }); } @@ -877,6 +983,8 @@ async function acquireWebSocket( headers: Headers, sessionId: string | undefined, signal?: AbortSignal, + connectTimeoutMs?: number, + env?: ProviderEnv, ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocketConnection; @@ -884,17 +992,11 @@ async function acquireWebSocket( release: (options?: { keep?: boolean }) => void; }> { if (!sessionId) { - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); return { socket, reused: false, - release: ({ keep } = {}) => { - if (keep === false) { - closeWebSocketSilently(socket); - return; - } - closeWebSocketSilently(socket); - }, + release: () => closeWebSocketSilently(socket), }; } @@ -922,7 +1024,7 @@ async function acquireWebSocket( }; } if (cached.busy) { - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); return { socket, reused: false, @@ -937,7 +1039,7 @@ async function acquireWebSocket( } } - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); const entry: CachedWebSocketConnection = { socket, busy: true }; websocketSessionCache.set(sessionId, entry); return { @@ -1016,7 +1118,11 @@ async function decodeWebSocketData(data: unknown): Promise { return null; } -async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): AsyncGenerator> { +async function* parseWebSocket( + socket: WebSocketLike, + signal?: AbortSignal, + idleTimeoutMs?: number, +): AsyncGenerator> { const queue: Record[] = []; let pending: (() => void) | null = null; let done = false; @@ -1096,8 +1202,23 @@ async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): Asy continue; } if (done) break; - await new Promise((resolve) => { + let timeout: ReturnType | undefined; + await new Promise((resolve, reject) => { pending = resolve; + if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) { + timeout = setTimeout(() => { + const error = new Error(`WebSocket idle timeout after ${idleTimeoutMs}ms`); + failed = error; + done = true; + pending = null; + closeWebSocketSilently(socket, 1000, "idle_timeout"); + reject(error); + }, idleTimeoutMs); + } + }).finally(() => { + if (timeout) { + clearTimeout(timeout); + } }); } @@ -1194,9 +1315,18 @@ async function processWebSocketStream( stream: AssistantMessageEventStream, model: Model<"openai-codex-responses">, onStart: () => void, + idleTimeoutMs: number | undefined, + websocketConnectTimeoutMs: number | undefined, options?: OpenAICodexResponsesOptions, ): Promise { - const { socket, entry, reused, release } = await acquireWebSocket(url, headers, options?.sessionId, options?.signal); + const { socket, entry, reused, release } = await acquireWebSocket( + url, + headers, + options?.sessionId, + options?.signal, + websocketConnectTimeoutMs, + options?.env, + ); let keepConnection = true; const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto"; // ChatGPT Codex Responses rejects `store: true` ("Store must be set to false"). @@ -1225,7 +1355,7 @@ async function processWebSocketStream( socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( - mapCodexEvents(parseWebSocket(socket, options?.signal)), + mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), output, stream, onStart, @@ -1348,7 +1478,7 @@ function buildSSEHeaders( headers.set("content-type", "application/json"); if (sessionId) { - headers.set("session_id", sessionId); + headers.set("session-id", sessionId); headers.set("x-client-request-id", sessionId); } @@ -1369,6 +1499,6 @@ function buildWebSocketHeaders( headers.delete("openai-beta"); headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS); headers.set("x-client-request-id", requestId); - headers.set("session_id", requestId); + headers.set("session-id", requestId); return headers; } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 12b6a508..10c8e7e6 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -10,16 +10,18 @@ import type { ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, CacheRetention, + ChatTemplateKwargValue, Context, ImageContent, Message, Model, OpenAICompletionsCompat, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -33,6 +35,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; @@ -89,6 +92,8 @@ type ResolvedOpenAICompletionsCompat = Omit, " cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; }; +type ResolvedChatTemplateKwargValue = string | number | boolean | null; + type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam; type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { @@ -99,11 +104,11 @@ type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletion cache_control?: OpenAICompatCacheControl; }; -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -136,11 +141,14 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const compat = getCompat(model); - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.env); let params = buildParams(model, context, options, compat, cacheRetention); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -149,7 +157,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.chat.completions .create(params, requestOptions) @@ -428,7 +436,7 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -445,23 +453,23 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", } satisfies OpenAICompletionsOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-completions", + stream: streamOpenAICompletions, + streamSimple: streamSimpleOpenAICompletions, + }); +} + function createClient( model: Model<"openai-completions">, context: Context, - apiKey?: string, + apiKey: string, optionsHeaders?: Record, sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), + env?: ProviderEnv, ) { - if (!apiKey) { - if (!process.env.OPENAI_API_KEY) { - throw new Error( - "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.OPENAI_API_KEY; - } - const headers = { ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); @@ -494,7 +502,7 @@ function createClient( return new OpenAI({ apiKey, - baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders, }); @@ -505,7 +513,7 @@ function buildParams( context: Context, options?: OpenAICompletionsOptions, compat: ResolvedOpenAICompletionsCompat = getCompat(model), - cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention), + cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env), ) { const messages = convertMessages(model, context, compat); const cacheControl = getCompatCacheControl(compat, cacheRetention); @@ -561,7 +569,18 @@ function buildParams( } if (compat.thinkingFormat === "zai" && model.reasoning) { - (params as any).enable_thinking = !!options?.reasoningEffort; + const zaiParams = params as Omit & { + thinking?: { type: "enabled" | "disabled" }; + reasoning_effort?: string; + }; + zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort]; + const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort; + if (typeof effort === "string") { + zaiParams.reasoning_effort = effort; + } + } } else if (compat.thinkingFormat === "qwen" && model.reasoning) { (params as any).enable_thinking = !!options?.reasoningEffort; } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { @@ -569,9 +588,18 @@ function buildParams( enable_thinking: !!options?.reasoningEffort, preserve_thinking: true, }; + } else if (compat.thinkingFormat === "chat-template" && model.reasoning) { + const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat); + if (chatTemplateKwargs) { + (params as any).chat_template_kwargs = chatTemplateKwargs; + } } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { - (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; if (options?.reasoningEffort) { + (params as any).thinking = { type: "enabled" }; + } else if (model.thinkingLevelMap?.off !== null) { + (params as any).thinking = { type: "disabled" }; + } + if (options?.reasoningEffort && compat.supportsReasoningEffort) { (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } @@ -585,6 +613,11 @@ function buildParams( } else if (model.thinkingLevelMap?.off !== null) { openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; } + } else if (compat.thinkingFormat === "ant-ling" && model.reasoning && options?.reasoningEffort) { + const effort = model.thinkingLevelMap?.[options.reasoningEffort]; + if (typeof effort === "string") { + (params as typeof params & { reasoning?: { effort: string } }).reasoning = { effort }; + } } else if (compat.thinkingFormat === "together" && model.reasoning) { const togetherParams = params as Omit & { reasoning?: { enabled: boolean }; @@ -594,6 +627,13 @@ function buildParams( if (options?.reasoningEffort && compat.supportsReasoningEffort) { togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } + } else if (compat.thinkingFormat === "string-thinking" && model.reasoning) { + const stringThinkingParams = params as typeof params & { thinking?: string }; + if (options?.reasoningEffort) { + stringThinkingParams.thinking = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (model.thinkingLevelMap?.off !== null) { + stringThinkingParams.thinking = model.thinkingLevelMap?.off ?? "none"; + } } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; @@ -605,7 +645,7 @@ function buildParams( } // OpenRouter provider routing preferences - if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) { + if (model.compat?.openRouterRouting) { (params as any).provider = model.compat.openRouterRouting; } @@ -623,6 +663,44 @@ function buildParams( return params; } +function buildChatTemplateKwargs( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + compat: ResolvedOpenAICompletionsCompat, +): Record | undefined { + const kwargs: Record = {}; + + for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) { + const resolved = resolveChatTemplateKwargValue(model, options, value); + if (resolved !== undefined) { + kwargs[key] = resolved; + } + } + + return Object.keys(kwargs).length > 0 ? kwargs : undefined; +} + +function resolveChatTemplateKwargValue( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + value: ChatTemplateKwargValue, +): ResolvedChatTemplateKwargValue | undefined { + if (typeof value !== "object" || value === null) { + return value; + } + + const reasoningEffort = options?.reasoningEffort; + if (!reasoningEffort && value.omitWhenOff) { + return undefined; + } + if (value.$var === "thinking.enabled") { + return !!reasoningEffort; + } + + const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off; + return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined; +} + function getCompatCacheControl( compat: ResolvedOpenAICompletionsCompat, cacheRetention: CacheRetention, @@ -1071,14 +1149,22 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const provider = model.provider; const baseUrl = model.baseUrl; - const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isZai = + provider === "zai" || + provider === "zai-coding-cn" || + baseUrl.includes("api.z.ai") || + baseUrl.includes("open.bigmodel.cn"); const isTogether = provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); + const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); + const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); const isNonStandard = + isNvidia || provider === "cerebras" || baseUrl.includes("cerebras.ai") || provider === "xai" || @@ -1091,18 +1177,23 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet provider === "opencode" || baseUrl.includes("opencode.ai") || isCloudflareWorkersAI || - isCloudflareAiGateway; + isCloudflareAiGateway || + isAntLing; - const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; + const useMaxTokens = + baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); + const isOpenRouterDeveloperRoleModel = + isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; return { supportsStore: !isNonStandard, - supportsDeveloperRole: !isNonStandard, - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), + supportsReasoningEffort: + !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1115,16 +1206,25 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet ? "zai" : isTogether ? "together" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") - ? "openrouter" - : "openai", + : isAntLing + ? "ant-ling" + : isOpenRouter + ? "openrouter" + : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, - supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, - supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway), + supportsLongCacheRetention: !( + isTogether || + isCloudflareWorkersAI || + isCloudflareAiGateway || + isNvidia || + isAntLing + ), }; } @@ -1152,6 +1252,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, openRouterRouting: model.compat.openRouterRouting ?? {}, vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs, zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index b7bcd192..6fd59a44 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -124,7 +124,8 @@ export function convertResponsesMessages( const includeSystemPrompt = options?.includeSystemPrompt ?? true; if (includeSystemPrompt && context.systemPrompt) { - const role = model.reasoning ? "developer" : "system"; + const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined; + const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system"; messages.push({ role, content: sanitizeSurrogates(context.systemPrompt), @@ -166,6 +167,7 @@ export function convertResponsesMessages( assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api; + let textBlockIndex = 0; for (const block of msg.content) { if (block.type === "thinking") { @@ -176,10 +178,13 @@ export function convertResponsesMessages( } else if (block.type === "text") { const textBlock = block as TextContent; const parsedSignature = parseTextSignature(textBlock.textSignature); + const fallbackMessageId = + textBlockIndex === 0 ? `msg_pi_${msgIndex}` : `msg_pi_${msgIndex}_${textBlockIndex}`; + textBlockIndex++; // OpenAI requires id to be max 64 characters let msgId = parsedSignature?.id; if (!msgId) { - msgId = `msg_${msgIndex}`; + msgId = fallbackMessageId; } else if (msgId.length > 64) { msgId = `msg_${shortHash(msgId)}`; } @@ -451,7 +456,8 @@ export async function processResponsesStream( }); currentBlock = null; } else if (item.type === "message" && currentBlock?.type === "text") { - currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join(""); + currentBlock.text = + item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || ""; currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined); stream.push({ type: "text_end", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index e2d1ca80..b1692978 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,6 +1,6 @@ import OpenAI from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; +import { registerApiProvider } from "../api-registry.ts"; import { clampThinkingLevel } from "../models.ts"; import type { Api, @@ -9,6 +9,7 @@ import type { Context, Model, OpenAIResponsesCompat, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, @@ -16,6 +17,7 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; @@ -28,11 +30,11 @@ const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode" * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -40,6 +42,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention function getCompat(model: Model<"openai-responses">): Required { return { + supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, }; @@ -107,10 +110,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes try { // Create OpenAI client - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, options?.env); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -119,7 +125,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); @@ -161,7 +167,7 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -176,22 +182,22 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim } satisfies OpenAIResponsesOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-responses", + stream: streamOpenAIResponses, + streamSimple: streamSimpleOpenAIResponses, + }); +} + function createClient( model: Model<"openai-responses">, context: Context, - apiKey?: string, + apiKey: string, optionsHeaders?: Record, sessionId?: string, + env?: ProviderEnv, ) { - if (!apiKey) { - if (!process.env.OPENAI_API_KEY) { - throw new Error( - "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.OPENAI_API_KEY; - } - const compat = getCompat(model); const headers = { ...model.headers }; if (model.provider === "github-copilot") { @@ -226,7 +232,7 @@ function createClient( return new OpenAI({ apiKey, - baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders, }); @@ -235,7 +241,7 @@ function createClient( function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) { const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS); - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const compat = getCompat(model); const params: ResponseCreateParamsStreaming = { model: model.id, diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index 8fdcaaf0..6192f8a2 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -1,9 +1,14 @@ -import { clearApiProviders, registerApiProvider } from "../api-registry.ts"; +import { type ApiProvider, clearApiProviders, getApiProvider, registerApiProvider } from "../api-registry.ts"; +import { getImagesApiProvider, type ImagesApiProvider, registerImagesApiProvider } from "../images-api-registry.ts"; import type { Api, + AssistantImages, AssistantMessage, AssistantMessageEvent, - Context, + ImagesApi, + ImagesContext, + ImagesModel, + ImagesOptions, Model, SimpleStreamOptions, StreamFunction, @@ -20,70 +25,47 @@ import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.ts"; import type { OpenAICompletionsOptions } from "./openai-completions.ts"; import type { OpenAIResponsesOptions } from "./openai-responses.ts"; -interface LazyProviderModule< - TApi extends Api, - TOptions extends StreamOptions, - TSimpleOptions extends SimpleStreamOptions, -> { - stream: (model: Model, context: Context, options?: TOptions) => AsyncIterable; - streamSimple: ( - model: Model, - context: Context, - options?: TSimpleOptions, - ) => AsyncIterable; +interface RegisteringProviderModule { + register(): void; } -interface AnthropicProviderModule { - streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>; - streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>; +function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages { + return { + api: model.api, + provider: model.provider, + model: model.id, + output: [], + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; } -interface AzureOpenAIResponsesProviderModule { - streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions>; - streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-responses", SimpleStreamOptions>; -} - -interface GoogleProviderModule { - streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>; - streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>; -} - -interface GoogleVertexProviderModule { - streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions>; - streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions>; -} - -interface MistralProviderModule { - streamMistral: StreamFunction<"mistral-conversations", MistralOptions>; - streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions>; -} - -interface OpenAICodexResponsesProviderModule { - streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions>; - streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-responses", SimpleStreamOptions>; -} - -interface OpenAICompletionsProviderModule { - streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>; - streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>; -} - -interface OpenAIResponsesProviderModule { - streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>; - streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; +function createLazyImagesApiProvider( + api: TApi, + loadModule: () => Promise, +): ImagesApiProvider { + return { + api, + generateImages: async (model: ImagesModel, context: ImagesContext, options?: TOptions) => { + try { + const module = await loadModule(); + module.register(); + const provider = getImagesApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return await provider.generateImages(model, context, options); + } catch (error) { + return createLazyLoadErrorImages(model as ImagesModel<"openrouter-images">, error); + } + }, + }; } interface BedrockProviderModule { - streamBedrock: ( - model: Model<"bedrock-converse-stream">, - context: Context, - options?: BedrockOptions, - ) => AsyncIterable; - streamSimpleBedrock: ( - model: Model<"bedrock-converse-stream">, - context: Context, - options?: SimpleStreamOptions, - ) => AsyncIterable; + streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions>; + streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions>; } const importNodeOnlyProvider = (specifier: string): Promise => { @@ -91,42 +73,10 @@ const importNodeOnlyProvider = (specifier: string): Promise => { return import(runtimeSpecifier); }; -let anthropicProviderModulePromise: - | Promise> - | undefined; -let azureOpenAIResponsesProviderModulePromise: - | Promise> - | undefined; -let googleProviderModulePromise: - | Promise> - | undefined; -let googleVertexProviderModulePromise: - | Promise> - | undefined; -let mistralProviderModulePromise: - | Promise> - | undefined; -let openAICodexResponsesProviderModulePromise: - | Promise> - | undefined; -let openAICompletionsProviderModulePromise: - | Promise> - | undefined; -let openAIResponsesProviderModulePromise: - | Promise> - | undefined; -let bedrockProviderModuleOverride: - | LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions> - | undefined; -let bedrockProviderModulePromise: - | Promise> - | undefined; +let bedrockProviderModuleOverride: BedrockProviderModule | undefined; export function setBedrockProviderModule(module: BedrockProviderModule): void { - bedrockProviderModuleOverride = { - stream: module.streamBedrock, - streamSimple: module.streamSimpleBedrock, - }; + bedrockProviderModuleOverride = module; } function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable): void { @@ -159,15 +109,29 @@ function createLazyLoadErrorMessage(model: Model, error: }; } -function createLazyStream( - loadModule: () => Promise>, +async function loadAndRegisterProvider( + api: TApi, + loadModule: () => Promise, +) { + const module = await loadModule(); + module.register(); + const provider = getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; +} + +function createLazyStream( + api: TApi, + loadModule: () => Promise, ): StreamFunction { return (model, context, options) => { const outer = new AssistantMessageEventStream(); - loadModule() - .then((module) => { - const inner = module.stream(model, context, options); + loadAndRegisterProvider(api, loadModule) + .then((provider) => { + const inner = provider.stream(model, context, options); forwardStream(outer, inner); }) .catch((error) => { @@ -180,17 +144,16 @@ function createLazyStream(loadModule: () => Promise>): StreamFunction { +function createLazySimpleStream( + api: TApi, + loadModule: () => Promise, +): StreamFunction { return (model, context, options) => { const outer = new AssistantMessageEventStream(); - loadModule() - .then((module) => { - const inner = module.streamSimple(model, context, options); + loadAndRegisterProvider(api, loadModule) + .then((provider) => { + const inner = provider.streamSimple(model, context, options); forwardStream(outer, inner); }) .catch((error) => { @@ -203,201 +166,114 @@ function createLazySimpleStream< }; } -function loadAnthropicProviderModule(): Promise< - LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> -> { - anthropicProviderModulePromise ||= import("./anthropic.ts").then((module) => { - const provider = module as AnthropicProviderModule; - return { - stream: provider.streamAnthropic, - streamSimple: provider.streamSimpleAnthropic, - }; - }); - return anthropicProviderModulePromise; +function createLazyApiProvider( + api: TApi, + loadModule: () => Promise, +): ApiProvider { + return { + api, + stream: createLazyStream(api, loadModule), + streamSimple: createLazySimpleStream(api, loadModule), + }; } -function loadAzureOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> -> { - azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.ts").then((module) => { - const provider = module as AzureOpenAIResponsesProviderModule; - return { - stream: provider.streamAzureOpenAIResponses, - streamSimple: provider.streamSimpleAzureOpenAIResponses, - }; - }); - return azureOpenAIResponsesProviderModulePromise; -} - -function loadGoogleProviderModule(): Promise< - LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> -> { - googleProviderModulePromise ||= import("./google.ts").then((module) => { - const provider = module as GoogleProviderModule; - return { - stream: provider.streamGoogle, - streamSimple: provider.streamSimpleGoogle, - }; - }); - return googleProviderModulePromise; -} - -function loadGoogleVertexProviderModule(): Promise< - LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions> -> { - googleVertexProviderModulePromise ||= import("./google-vertex.ts").then((module) => { - const provider = module as GoogleVertexProviderModule; - return { - stream: provider.streamGoogleVertex, - streamSimple: provider.streamSimpleGoogleVertex, - }; - }); - return googleVertexProviderModulePromise; -} - -function loadMistralProviderModule(): Promise< - LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions> -> { - mistralProviderModulePromise ||= import("./mistral.ts").then((module) => { - const provider = module as MistralProviderModule; - return { - stream: provider.streamMistral, - streamSimple: provider.streamSimpleMistral, - }; - }); - return mistralProviderModulePromise; -} - -function loadOpenAICodexResponsesProviderModule(): Promise< - LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> -> { - openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.ts").then((module) => { - const provider = module as OpenAICodexResponsesProviderModule; - return { - stream: provider.streamOpenAICodexResponses, - streamSimple: provider.streamSimpleOpenAICodexResponses, - }; - }); - return openAICodexResponsesProviderModulePromise; -} - -function loadOpenAICompletionsProviderModule(): Promise< - LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> -> { - openAICompletionsProviderModulePromise ||= import("./openai-completions.ts").then((module) => { - const provider = module as OpenAICompletionsProviderModule; - return { - stream: provider.streamOpenAICompletions, - streamSimple: provider.streamSimpleOpenAICompletions, - }; - }); - return openAICompletionsProviderModulePromise; -} - -function loadOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> -> { - openAIResponsesProviderModulePromise ||= import("./openai-responses.ts").then((module) => { - const provider = module as OpenAIResponsesProviderModule; - return { - stream: provider.streamOpenAIResponses, - streamSimple: provider.streamSimpleOpenAIResponses, - }; - }); - return openAIResponsesProviderModulePromise; -} - -function loadBedrockProviderModule(): Promise< - LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions> -> { - if (bedrockProviderModuleOverride) { - return Promise.resolve(bedrockProviderModuleOverride); - } - bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.ts").then((module) => { - const provider = module as BedrockProviderModule; - return { - stream: provider.streamBedrock, - streamSimple: provider.streamSimpleBedrock, - }; - }); - return bedrockProviderModulePromise; -} - -export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); -export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule); -export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); -export const streamSimpleAzureOpenAIResponses = createLazySimpleStream(loadAzureOpenAIResponsesProviderModule); -export const streamGoogle = createLazyStream(loadGoogleProviderModule); -export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule); -export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); -export const streamSimpleGoogleVertex = createLazySimpleStream(loadGoogleVertexProviderModule); -export const streamMistral = createLazyStream(loadMistralProviderModule); -export const streamSimpleMistral = createLazySimpleStream(loadMistralProviderModule); -export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); -export const streamSimpleOpenAICodexResponses = createLazySimpleStream(loadOpenAICodexResponsesProviderModule); -export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); -export const streamSimpleOpenAICompletions = createLazySimpleStream(loadOpenAICompletionsProviderModule); -export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); -export const streamSimpleOpenAIResponses = createLazySimpleStream(loadOpenAIResponsesProviderModule); -const streamBedrockLazy = createLazyStream(loadBedrockProviderModule); -const streamSimpleBedrockLazy = createLazySimpleStream(loadBedrockProviderModule); - -export function registerBuiltInApiProviders(): void { - registerApiProvider({ - api: "anthropic-messages", - stream: streamAnthropic, - streamSimple: streamSimpleAnthropic, - }); - - registerApiProvider({ - api: "openai-completions", - stream: streamOpenAICompletions, - streamSimple: streamSimpleOpenAICompletions, - }); - - registerApiProvider({ - api: "mistral-conversations", - stream: streamMistral, - streamSimple: streamSimpleMistral, - }); - - registerApiProvider({ - api: "openai-responses", - stream: streamOpenAIResponses, - streamSimple: streamSimpleOpenAIResponses, - }); - - registerApiProvider({ - api: "azure-openai-responses", - stream: streamAzureOpenAIResponses, - streamSimple: streamSimpleAzureOpenAIResponses, - }); - - registerApiProvider({ - api: "openai-codex-responses", - stream: streamOpenAICodexResponses, - streamSimple: streamSimpleOpenAICodexResponses, - }); - - registerApiProvider({ - api: "google-generative-ai", - stream: streamGoogle, - streamSimple: streamSimpleGoogle, - }); - - registerApiProvider({ - api: "google-vertex", - stream: streamGoogleVertex, - streamSimple: streamSimpleGoogleVertex, - }); - +function registerBedrockProviderModule(module: BedrockProviderModule): void { registerApiProvider({ api: "bedrock-converse-stream", - stream: streamBedrockLazy, - streamSimple: streamSimpleBedrockLazy, + stream: module.streamBedrock, + streamSimple: module.streamSimpleBedrock, }); } +function loadBedrockProviderModule(): Promise { + const module = bedrockProviderModuleOverride; + if (module) { + return Promise.resolve({ register: () => registerBedrockProviderModule(module) }); + } + return importNodeOnlyProvider("./amazon-bedrock.ts").then((provider) => provider as RegisteringProviderModule); +} + +const anthropicProvider = createLazyApiProvider<"anthropic-messages", AnthropicOptions>( + "anthropic-messages", + () => import("./anthropic.ts"), +); +const azureOpenAIResponsesProvider = createLazyApiProvider<"azure-openai-responses", AzureOpenAIResponsesOptions>( + "azure-openai-responses", + () => import("./azure-openai-responses.ts"), +); +const googleProvider = createLazyApiProvider<"google-generative-ai", GoogleOptions>( + "google-generative-ai", + () => import("./google.ts"), +); +const googleVertexProvider = createLazyApiProvider<"google-vertex", GoogleVertexOptions>( + "google-vertex", + () => import("./google-vertex.ts"), +); +const mistralProvider = createLazyApiProvider<"mistral-conversations", MistralOptions>( + "mistral-conversations", + () => import("./mistral.ts"), +); +const openAICodexResponsesProvider = createLazyApiProvider<"openai-codex-responses", OpenAICodexResponsesOptions>( + "openai-codex-responses", + () => import("./openai-codex-responses.ts"), +); +const openAICompletionsProvider = createLazyApiProvider<"openai-completions", OpenAICompletionsOptions>( + "openai-completions", + () => import("./openai-completions.ts"), +); +const openAIResponsesProvider = createLazyApiProvider<"openai-responses", OpenAIResponsesOptions>( + "openai-responses", + () => import("./openai-responses.ts"), +); +const bedrockProvider = createLazyApiProvider<"bedrock-converse-stream", BedrockOptions>( + "bedrock-converse-stream", + loadBedrockProviderModule, +); +const openRouterImagesProvider = createLazyImagesApiProvider( + "openrouter-images", + () => import("./images/openrouter.ts"), +); + +export const generateImagesOpenRouter = openRouterImagesProvider.generateImages; +export const streamAnthropic = anthropicProvider.stream; +export const streamSimpleAnthropic = anthropicProvider.streamSimple; +export const streamAzureOpenAIResponses = azureOpenAIResponsesProvider.stream; +export const streamSimpleAzureOpenAIResponses = azureOpenAIResponsesProvider.streamSimple; +export const streamGoogle = googleProvider.stream; +export const streamSimpleGoogle = googleProvider.streamSimple; +export const streamGoogleVertex = googleVertexProvider.stream; +export const streamSimpleGoogleVertex = googleVertexProvider.streamSimple; +export const streamMistral = mistralProvider.stream; +export const streamSimpleMistral = mistralProvider.streamSimple; +export const streamOpenAICodexResponses = openAICodexResponsesProvider.stream; +export const streamSimpleOpenAICodexResponses = openAICodexResponsesProvider.streamSimple; +export const streamOpenAICompletions = openAICompletionsProvider.stream; +export const streamSimpleOpenAICompletions = openAICompletionsProvider.streamSimple; +export const streamOpenAIResponses = openAIResponsesProvider.stream; +export const streamSimpleOpenAIResponses = openAIResponsesProvider.streamSimple; + +const registerBuiltInApiProviderFunctions = [ + () => registerApiProvider(anthropicProvider), + () => registerApiProvider(openAICompletionsProvider), + () => registerApiProvider(mistralProvider), + () => registerApiProvider(openAIResponsesProvider), + () => registerApiProvider(azureOpenAIResponsesProvider), + () => registerApiProvider(openAICodexResponsesProvider), + () => registerApiProvider(googleProvider), + () => registerApiProvider(googleVertexProvider), + () => registerApiProvider(bedrockProvider), +]; + +export function registerBuiltInImagesApiProviders(): void { + registerImagesApiProvider(openRouterImagesProvider); +} + +export function registerBuiltInApiProviders(): void { + for (const register of registerBuiltInApiProviderFunctions) { + register(); + } +} + export function resetApiProviders(): void { clearApiProviders(); registerBuiltInApiProviders(); diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index 7f0aeea7..773e8d95 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -13,9 +13,11 @@ export function buildBaseOptions(_model: Model, options?: SimpleStreamOptio onPayload: options?.onPayload, onResponse: options?.onResponse, timeoutMs: options?.timeoutMs, + websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs, maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, + env: options?.env, }; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index a38b01fd..7b6b28c6 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,6 +1,5 @@ -import "./providers/register-builtins.ts"; - import { getApiProvider } from "./api-registry.ts"; +import { getEnvApiKey } from "./env-api-keys.ts"; import type { Api, AssistantMessage, @@ -12,7 +11,19 @@ import type { StreamOptions, } from "./types.ts"; -export { getEnvApiKey } from "./env-api-keys.ts"; +function hasExplicitApiKey(apiKey: string | undefined): apiKey is string { + return typeof apiKey === "string" && apiKey.trim().length > 0; +} + +function withEnvApiKey( + model: Model, + options: TOptions | undefined, +): TOptions | undefined { + if (hasExplicitApiKey(options?.apiKey)) return options; + const apiKey = getEnvApiKey(model.provider, options?.env); + if (!apiKey) return options; + return { ...options, apiKey } as TOptions; +} function resolveApiProvider(api: Api) { const provider = getApiProvider(api); @@ -28,7 +39,7 @@ export function stream( options?: ProviderStreamOptions, ): AssistantMessageEventStream { const provider = resolveApiProvider(model.api); - return provider.stream(model, context, options as StreamOptions); + return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions); } export async function complete( @@ -46,7 +57,7 @@ export function streamSimple( options?: SimpleStreamOptions, ): AssistantMessageEventStream { const provider = resolveApiProvider(model.api); - return provider.streamSimple(model, context, options); + return provider.streamSimple(model, context, withEnvApiKey(model, options)); } export async function completeSimple( diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6fc703f1..54f44685 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -22,12 +22,14 @@ export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = | "amazon-bedrock" + | "ant-ling" | "anthropic" | "google" | "google-vertex" | "openai" | "azure-openai-responses" | "openai-codex" + | "nvidia" | "deepseek" | "github-copilot" | "xai" @@ -36,6 +38,7 @@ export type KnownProvider = | "openrouter" | "vercel-ai-gateway" | "zai" + | "zai-coding-cn" | "mistral" | "minimax" | "minimax-cn" @@ -62,6 +65,15 @@ export type ImagesProvider = KnownImagesProvider | string; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; export type ModelThinkingLevel = "off" | ThinkingLevel; export type ThinkingLevelMap = Partial>; +export type ChatTemplateKwargValue = + | string + | number + | boolean + | null + | { + $var: "thinking.enabled" | "thinking.effort"; + omitWhenOff?: boolean; + }; /** Token budgets for each thinking level (token-based providers only) */ export interface ThinkingBudgets { @@ -76,6 +88,9 @@ export type CacheRetention = "none" | "short" | "long"; export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; +/** Provider-scoped environment overrides. Values take precedence over process.env. */ +export type ProviderEnv = Record; + export interface ProviderResponse { status: number; headers: Record; @@ -114,8 +129,10 @@ export interface StreamOptions { onResponse?: (response: ProviderResponse, model: Model) => void | Promise; /** * Optional custom HTTP headers to include in API requests. - * Merged with provider defaults; can override default headers. - * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). + * Merged with provider defaults; caller values override default headers. + * On AWS Bedrock these are injected via a Smithy `build`-step middleware so + * they are covered by SigV4 signing; reserved headers (`x-amz-*`, + * `authorization`, `host`) are silently ignored to preserve SigV4 / bearer auth. */ headers?: Record; /** @@ -123,6 +140,12 @@ export interface StreamOptions { * For example, OpenAI and Anthropic SDK clients default to 10 minutes. */ timeoutMs?: number; + /** + * WebSocket connect timeout in milliseconds for providers that support + * WebSocket transports. This covers the connection/open handshake only; + * stream idleness after connection uses timeoutMs. + */ + websocketConnectTimeoutMs?: number; /** * Maximum retry attempts for providers/SDKs that support client-side retries. * For example, OpenAI and Anthropic SDK clients default to 2. @@ -142,6 +165,12 @@ export interface StreamOptions { * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. */ metadata?: Record; + /** + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as regional settings, endpoint placeholders, and + * proxy variables. + */ + env?: ProviderEnv; } export type ProviderStreamOptions = StreamOptions & Record; @@ -256,6 +285,8 @@ export interface Usage { output: number; cacheRead: number; cacheWrite: number; + /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ + cacheWrite1h?: number; totalTokens: number; cost: { input: number; @@ -381,9 +412,21 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; - /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ + thinkingFormat?: + | "openai" + | "openrouter" + | "deepseek" + | "together" + | "zai" + | "qwen" + | "chat-template" + | "qwen-chat-template" + | "string-thinking" + | "ant-ling"; + /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + chatTemplateKwargs?: Record; + /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ vercelGatewayRouting?: VercelGatewayRouting; @@ -401,6 +444,8 @@ export interface OpenAICompletionsCompat { /** Compatibility settings for OpenAI Responses APIs. */ export interface OpenAIResponsesCompat { + /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ + supportsDeveloperRole?: boolean; /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ sendSessionIdHeader?: boolean; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ @@ -435,6 +480,24 @@ export interface AnthropicMessagesCompat { * Default: true. */ supportsCacheControlOnTools?: boolean; + /** + * Whether the model accepts the Anthropic `temperature` request field. + * Claude Opus 4.7+ rejects non-default temperature values. + * Default: true. + */ + supportsTemperature?: boolean; + /** + * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus + * `output_config.effort`) regardless of the model id. Built-in models that + * require adaptive thinking set this in generated metadata. Custom + * Anthropic-compatible providers can set this to `true` for any model whose + * upstream requires the adaptive format. Set to `false` to + * opt out on overridden built-in models. + * Default: false. + */ + forceAdaptiveThinking?: boolean; + /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ + allowEmptySignature?: boolean; } /** diff --git a/packages/ai/src/utils/abort-signals.ts b/packages/ai/src/utils/abort-signals.ts new file mode 100644 index 00000000..8e1a9ade --- /dev/null +++ b/packages/ai/src/utils/abort-signals.ts @@ -0,0 +1,41 @@ +export interface CombinedAbortSignal { + signal?: AbortSignal; + cleanup: () => void; +} + +export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal { + const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined); + if (activeSignals.length === 0) { + return { cleanup: () => {} }; + } + if (activeSignals.length === 1) { + return { signal: activeSignals[0], cleanup: () => {} }; + } + + const controller = new AbortController(); + const listeners: Array<{ signal: AbortSignal; listener: () => void }> = []; + const abort = (signal: AbortSignal) => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); + } + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abort(signal); + break; + } + const listener = () => abort(signal); + signal.addEventListener("abort", listener, { once: true }); + listeners.push({ signal, listener }); + } + + return { + signal: controller.signal, + cleanup: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener("abort", listener); + } + }, + }; +} diff --git a/packages/ai/src/utils/node-http-proxy.ts b/packages/ai/src/utils/node-http-proxy.ts index 7b5f4750..81ff0089 100644 --- a/packages/ai/src/utils/node-http-proxy.ts +++ b/packages/ai/src/utils/node-http-proxy.ts @@ -1,7 +1,5 @@ -import type { Agent as HttpAgent } from "node:http"; -import type { Agent as HttpsAgent } from "node:https"; -import { HttpProxyAgent } from "http-proxy-agent"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import type { ProviderEnv } from "../types.ts"; +import { getProviderEnvValue } from "./provider-env.ts"; const DEFAULT_PROXY_PORTS: Record = { ftp: 21, @@ -12,16 +10,16 @@ const DEFAULT_PROXY_PORTS: Record = { wss: 443, }; -export interface NodeHttpProxyAgents { - httpAgent: HttpAgent; - httpsAgent: HttpsAgent; -} - -export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = - "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; - -function getProxyEnv(key: string): string { - return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +function getProxyEnv(key: string, env?: ProviderEnv): string { + const lowercaseKey = key.toLowerCase(); + const uppercaseKey = key.toUpperCase(); + return ( + env?.[lowercaseKey] || + env?.[uppercaseKey] || + getProviderEnvValue(lowercaseKey) || + getProviderEnvValue(uppercaseKey) || + "" + ); } function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { @@ -36,8 +34,8 @@ function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { } } -function shouldProxyHostname(hostname: string, port: number): boolean { - const noProxy = getProxyEnv("no_proxy").toLowerCase(); +function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean { + const noProxy = getProxyEnv("no_proxy", env).toLowerCase(); if (!noProxy) { return true; } @@ -68,7 +66,7 @@ function shouldProxyHostname(hostname: string, port: number): boolean { }); } -function getProxyForUrl(targetUrl: string | URL): string { +function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string { const parsedUrl = parseProxyTargetUrl(targetUrl); if (!parsedUrl?.protocol || !parsedUrl.host) { return ""; @@ -77,19 +75,22 @@ function getProxyForUrl(targetUrl: string | URL): string { const protocol = parsedUrl.protocol.split(":", 1)[0]!; const hostname = parsedUrl.host.replace(/:\d*$/, ""); const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; - if (!shouldProxyHostname(hostname, port)) { + if (!shouldProxyHostname(hostname, port, env)) { return ""; } - let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env); if (proxy && !proxy.includes("://")) { proxy = `${protocol}://${proxy}`; } return proxy; } -export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { - const proxy = getProxyForUrl(targetUrl); +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; + +export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined { + const proxy = getProxyForUrl(targetUrl, env); if (!proxy) { return undefined; } @@ -109,15 +110,3 @@ export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | und return proxyUrl; } - -export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined { - const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl); - if (!proxyUrl) { - return undefined; - } - - return { - httpAgent: new HttpProxyAgent(proxyUrl), - httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, - }; -} diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index feeb34fa..1b3e4244 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -6,6 +6,7 @@ */ import type { Server } from "node:http"; +import { getProviderEnvValue } from "../provider-env.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; @@ -28,7 +29,7 @@ const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; +const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts new file mode 100644 index 00000000..1121136f --- /dev/null +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -0,0 +1,83 @@ +const CANCEL_MESSAGE = "Login cancelled"; +const TIMEOUT_MESSAGE = "Device flow timed out"; +const SLOW_DOWN_TIMEOUT_MESSAGE = + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again."; +const MINIMUM_INTERVAL_MS = 1000; +// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds. +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. +const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; + +type OAuthDeviceCodeIncompletePollResult = + | { status: "pending" } + | { status: "slow_down" } + | { status: "failed"; message: string }; + +export type OAuthDeviceCodePollResult = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T }; + +export type OAuthDeviceCodePollOptions = { + intervalSeconds?: number; + expiresInSeconds?: number; + poll: () => Promise>; + signal?: AbortSignal; +}; + +function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(cancelMessage)); + return; + } + + const onAbort = () => { + clearTimeout(timeout); + reject(new Error(cancelMessage)); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { + const deadline = + typeof options.expiresInSeconds === "number" + ? Date.now() + options.expiresInSeconds * 1000 + : Number.POSITIVE_INFINITY; + let intervalMs = Math.max( + MINIMUM_INTERVAL_MS, + Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000), + ); + + let slowDownResponses = 0; + while (Date.now() < deadline) { + if (options.signal?.aborted) { + throw new Error(CANCEL_MESSAGE); + } + + const result = await options.poll(); + if (result.status === "complete") { + return result.value; + } + if (result.status === "failed") { + throw new Error(result.message); + } + if (result.status === "slow_down") { + slowDownResponses += 1; + // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. + intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); + } + + throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); +} diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6182ab91..bb78a93a 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -4,10 +4,12 @@ import { getModels } from "../../models.ts"; import type { Api, Model } from "../../types.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; type CopilotCredentials = OAuthCredentials & { enterpriseUrl?: string; + availableModelIds: string[]; }; const decode = (s: string) => atob(s); @@ -19,15 +21,13 @@ const COPILOT_HEADERS = { "Editor-Plugin-Version": "copilot-chat/0.35.0", "Copilot-Integration-Id": "vscode-chat", } as const; - -const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; -const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; +const COPILOT_API_VERSION = "2026-06-01"; type DeviceCodeResponse = { device_code: string; user_code: string; verification_uri: string; - interval: number; + interval?: number; expires_in: number; }; @@ -40,7 +40,6 @@ type DeviceTokenSuccessResponse = { type DeviceTokenErrorResponse = { error: string; error_description?: string; - interval?: number; }; export function normalizeDomain(input: string): string | null { @@ -91,6 +90,48 @@ export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: strin return "https://api.individual.githubcopilot.com"; } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function isSelectableCopilotModel(item: Record): boolean { + const policy = asRecord(item.policy); + const capabilities = asRecord(item.capabilities); + const supports = asRecord(capabilities?.supports); + return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false; +} + +function parseAvailableCopilotModelIds(raw: unknown): string[] { + const data = asRecord(raw)?.data; + if (!Array.isArray(data)) { + throw new Error("Invalid Copilot models response"); + } + + const ids: string[] = []; + for (const rawItem of data) { + const item = asRecord(rawItem); + const id = item?.id; + if (typeof id === "string" && item && isSelectableCopilotModel(item)) { + ids.push(id); + } + } + return ids; +} + +async function fetchAvailableGitHubCopilotModelIds(copilotToken: string, enterpriseDomain?: string): Promise { + const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain); + const raw = await fetchJson(`${baseUrl}/models`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + "X-GitHub-Api-Version": COPILOT_API_VERSION, + }, + signal: AbortSignal.timeout(5000), + }); + return parseAvailableCopilotModelIds(raw); +} + async function fetchJson(url: string, init: RequestInit): Promise { const response = await fetch(url, init); if (!response.ok) { @@ -129,116 +170,82 @@ async function startDeviceFlow(domain: string): Promise { typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string" || - typeof interval !== "number" || + (interval !== undefined && typeof interval !== "number") || typeof expiresIn !== "number" ) { throw new Error("Invalid device code response fields"); } + // The verification URI is opened in the user's browser and to prevent `open` from + // opening an executable or similar, we force it to be a URL. + let parsedUri: URL; + try { + parsedUri = new URL(verificationUri); + } catch { + throw new Error("Untrusted verification_uri in device code response"); + } + if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") { + throw new Error("Untrusted verification_uri in device code response"); + } + return { device_code: deviceCode, user_code: userCode, - verification_uri: verificationUri, + verification_uri: parsedUri.href, interval, expires_in: expiresIn, }; } -/** - * Sleep that can be interrupted by an AbortSignal - */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } +async function pollForGitHubAccessToken( + domain: string, + device: DeviceCodeResponse, + signal?: AbortSignal, +): Promise { + const urls = getUrls(domain); + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + signal, + poll: async () => { + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); - const timeout = setTimeout(resolve, ms); + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { + return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token }; + } - signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(new Error("Login cancelled")); - }, - { once: true }, - ); + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { + const { error, error_description: description } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { + return { status: "pending" }; + } + + if (error === "slow_down") { + return { status: "slow_down" }; + } + + const descriptionSuffix = description ? `: ${description}` : ""; + return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` }; + } + + return { status: "failed", message: "Invalid device token response" }; + }, }); } -async function pollForGitHubAccessToken( - domain: string, - deviceCode: string, - intervalSeconds: number, - expiresIn: number, - signal?: AbortSignal, -) { - const urls = getUrls(domain); - const deadline = Date.now() + expiresIn * 1000; - let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); - let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; - let slowDownResponses = 0; - - while (Date.now() < deadline) { - if (signal?.aborted) { - throw new Error("Login cancelled"); - } - - const remainingMs = deadline - Date.now(); - const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); - await abortableSleep(waitMs, signal); - - const raw = await fetchJson(urls.accessTokenUrl, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "GitHubCopilotChat/0.35.0", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { - return (raw as DeviceTokenSuccessResponse).access_token; - } - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; - if (error === "authorization_pending") { - continue; - } - - if (error === "slow_down") { - slowDownResponses += 1; - intervalMs = - typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000); - intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; - continue; - } - - const descriptionSuffix = description ? `: ${description}` : ""; - throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); - } - } - - if (slowDownResponses > 0) { - throw new Error( - "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", - ); - } - - throw new Error("Device flow timed out"); -} - -/** - * Refresh GitHub Copilot token - */ -export async function refreshGitHubCopilotToken( +async function refreshGitHubCopilotAccessToken( refreshToken: string, enterpriseDomain?: string, ): Promise { @@ -272,6 +279,20 @@ export async function refreshGitHubCopilotToken( }; } +/** + * Refresh GitHub Copilot token + */ +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise { + const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain); + return { + ...credentials, + availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain), + }; +} + /** * Enable a model for the user's GitHub Copilot account. * This is required for some models (like Claude, Grok) before they can be used. @@ -319,13 +340,13 @@ async function enableAllGitHubCopilotModels( /** * Login with GitHub Copilot OAuth (device code flow) * - * @param options.onAuth - Callback with URL and optional instructions (user code) + * @param options.onDeviceCode - Callback with URL and user code * @param options.onPrompt - Callback to prompt user for input * @param options.onProgress - Optional progress callback * @param options.signal - Optional AbortSignal for cancellation */ export async function loginGitHubCopilot(options: { - onAuth: (url: string, instructions?: string) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; @@ -348,21 +369,26 @@ export async function loginGitHubCopilot(options: { const domain = enterpriseDomain || "github.com"; const device = await startDeviceFlow(domain); - options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); + options.onDeviceCode({ + userCode: device.user_code, + verificationUri: device.verification_uri, + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + }); - const githubAccessToken = await pollForGitHubAccessToken( - domain, - device.device_code, - device.interval, - device.expires_in, - options.signal, - ); - const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); + const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); + const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined); // Enable all models after successful login options.onProgress?.("Enabling models..."); await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); - return credentials; + + // Fetch availability after policy enable so newly enabled models are included, + // while unavailable models are still filtered out. + return { + ...credentials, + availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined), + }; } export const githubCopilotOAuthProvider: OAuthProviderInterface = { @@ -371,7 +397,7 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { async login(callbacks: OAuthLoginCallbacks): Promise { return loginGitHubCopilot({ - onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), + onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, signal: callbacks.signal, @@ -391,6 +417,14 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { const creds = credentials as CopilotCredentials; const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined; const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain); - return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + // Older stored Pi auth entries do not have account-specific model IDs yet; + // keep their existing generated-catalog behavior until the next refresh/login. + const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined; + + return models.flatMap((m) => { + if (m.provider !== "github-copilot") return [m]; + if (availableModelIds && !availableModelIds.has(m.id)) return []; + return [{ ...m, baseUrl }]; + }); }, }; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 3a3a01b1..a57badda 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -9,6 +9,7 @@ // Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; +export * from "./device-code.ts"; // GitHub Copilot export { getGitHubCopilotBaseUrl, @@ -18,7 +19,14 @@ export { refreshGitHubCopilotToken, } from "./github-copilot.ts"; // OpenAI Codex (ChatGPT OAuth) -export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts"; +export { + loginOpenAICodex, + loginOpenAICodexDeviceCode, + OPENAI_CODEX_BROWSER_LOGIN_METHOD, + OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "./openai-codex.ts"; export * from "./types.ts"; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 7dd1fbb8..a5103c39 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,21 +17,50 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { getProviderEnvValue } from "../provider-env.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; +import type { + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.ts"; -const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; -const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const AUTH_BASE_URL = "https://auth.openai.com"; +const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`; +const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`; const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`; +const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`; +const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`; +const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; +const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; +export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; +export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; -type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed"; message: string; status?: number }; -type TokenResult = TokenSuccess | TokenFailure; +type OAuthToken = { access: string; refresh: string; expires: number }; +type TokenOperation = "exchange" | "refresh"; + +function getCallbackHost(): string { + return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; +} + +type DeviceAuthInfo = { + deviceAuthId: string; + userCode: string; + intervalSeconds: number; +}; + +type DeviceTokenSuccess = { + authorizationCode: string; + codeVerifier: string; +}; type JwtPayload = { [JWT_CLAIM_PATH]?: { @@ -89,12 +118,47 @@ function decodeJwt(token: string): JwtPayload | null { } } +async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (error) { + if (init.signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } +} + +async function readTokenResponse(response: Response, operation: TokenOperation): Promise { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`); + } + + const rawJson = await response.json(); + const json = rawJson as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + } | null; + if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`); + } + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + async function exchangeAuthorizationCode( code: string, verifier: string, redirectUri: string = REDIRECT_URI, -): Promise { - const response = await fetch(TOKEN_URL, { + signal?: AbortSignal, +): Promise { + const response = await fetchWithLoginCancellation(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -104,41 +168,16 @@ async function exchangeAuthorizationCode( code_verifier: verifier, redirect_uri: redirectUri, }), + signal, }); - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; + return readTokenResponse(response, "exchange"); } -async function refreshAccessToken(refreshToken: string): Promise { +async function refreshAccessToken(refreshToken: string): Promise { + let response: Response; try { - const response = await fetch(TOKEN_URL, { + response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -147,41 +186,113 @@ async function refreshAccessToken(refreshToken: string): Promise { client_id: CLIENT_ID, }), }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; } catch (error) { - return { - type: "failed", - message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, - }; + throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`); } + + return readTokenResponse(response, "refresh"); +} + +async function startOpenAICodexDeviceAuth(signal?: AbortSignal): Promise { + const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID }), + signal, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error( + "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.", + ); + } + const responseBody = await response.text().catch(() => ""); + throw new Error( + `OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + ); + } + + const rawJson = await response.json(); + const json = rawJson as { + device_auth_id?: string; + user_code?: string; + interval?: number | string; + } | null; + const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval; + if ( + !json?.device_auth_id || + !json.user_code || + typeof intervalSeconds !== "number" || + !Number.isFinite(intervalSeconds) || + intervalSeconds < 0 + ) { + throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`); + } + + return { + deviceAuthId: json.device_auth_id, + userCode: json.user_code, + intervalSeconds, + }; +} + +async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal?: AbortSignal): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + signal, + poll: async () => { + const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: device.deviceAuthId, + user_code: device.userCode, + }), + signal, + }); + + if (response.ok) { + const rawJson = await response.json(); + const json = rawJson as { authorization_code?: string; code_verifier?: string } | null; + if (!json?.authorization_code || !json.code_verifier) { + return { + status: "failed", + message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`, + }; + } + return { + status: "complete", + value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier }, + }; + } + + if (response.status === 403 || response.status === 404) { + return { status: "pending" }; + } + + const responseBody = await response.text().catch(() => ""); + let errorCode: unknown; + try { + const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null; + const error = json?.error; + errorCode = typeof error === "object" ? error?.code : error; + } catch {} + + if (errorCode === "deviceauth_authorization_pending") { + return { status: "pending" }; + } + if (errorCode === "slow_down") { + return { status: "slow_down" }; + } + + return { + status: "failed", + message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + }; + }, + }); } async function createAuthorizationFlow( @@ -261,7 +372,7 @@ function startLocalOAuthServer(state: string): Promise { return new Promise((resolve) => { server - .listen(1455, CALLBACK_HOST, () => { + .listen(1455, getCallbackHost(), () => { resolve({ close: () => server.close(), cancelWait: () => { @@ -294,6 +405,52 @@ function getAccountId(accessToken: string): string | null { return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } +function credentialsFromToken(token: OAuthToken): OAuthCredentials { + const accountId = getAccountId(token.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: token.access, + refresh: token.refresh, + expires: token.expires, + accountId, + }; +} + +async function exchangeAuthorizationCodeForCredentials( + code: string, + verifier: string, + redirectUri: string, + signal?: AbortSignal, +): Promise { + return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); +} + +/** + * Login with OpenAI Codex OAuth using the Codex device-code flow. + */ +export async function loginOpenAICodexDeviceCode(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + signal?: AbortSignal; +}): Promise { + const device = await startOpenAICodexDeviceAuth(options.signal); + options.onDeviceCode({ + userCode: device.userCode, + verificationUri: DEVICE_VERIFICATION_URI, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + }); + const code = await pollOpenAICodexDeviceAuth(device, options.signal); + return exchangeAuthorizationCodeForCredentials( + code.authorizationCode, + code.codeVerifier, + DEVICE_REDIRECT_URI, + options.signal, + ); +} + /** * Login with OpenAI Codex OAuth * @@ -391,22 +548,7 @@ export async function loginOpenAICodex(options: { throw new Error("Missing authorization code"); } - const tokenResult = await exchangeAuthorizationCode(code, verifier); - if (tokenResult.type !== "success") { - throw new Error(tokenResult.message); - } - - const accountId = getAccountId(tokenResult.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: tokenResult.access, - refresh: tokenResult.refresh, - expires: tokenResult.expires, - accountId, - }; + return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI); } finally { server.close(); } @@ -416,22 +558,7 @@ export async function loginOpenAICodex(options: { * Refresh OpenAI Codex OAuth token */ export async function refreshOpenAICodexToken(refreshToken: string): Promise { - const result = await refreshAccessToken(refreshToken); - if (result.type !== "success") { - throw new Error(result.message); - } - - const accountId = getAccountId(result.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: result.access, - refresh: result.refresh, - expires: result.expires, - accountId, - }; + return credentialsFromToken(await refreshAccessToken(refreshToken)); } export const openaiCodexOAuthProvider: OAuthProviderInterface = { @@ -440,6 +567,28 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = { usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { + const loginMethod = await callbacks.onSelect({ + message: "Select OpenAI Codex login method:", + options: [ + { id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, + { id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" }, + ], + }); + if (!loginMethod) { + throw new Error("Login cancelled"); + } + + if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { + return loginOpenAICodexDeviceCode({ + onDeviceCode: callbacks.onDeviceCode, + signal: callbacks.signal, + }); + } + + if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { + throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`); + } + return loginOpenAICodex({ onAuth: callbacks.onAuth, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index a1426d81..008be405 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -23,6 +23,13 @@ export type OAuthAuthInfo = { instructions?: string; }; +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + export type OAuthSelectOption = { id: string; label: string; @@ -35,11 +42,12 @@ export type OAuthSelectPrompt = { export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; /** Show an interactive selector and return the selected option id, or undefined on cancel. */ - onSelect?: (prompt: OAuthSelectPrompt) => Promise; + onSelect: (prompt: OAuthSelectPrompt) => Promise; signal?: AbortSignal; } diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 8b908e85..623b873a 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -12,10 +12,12 @@ import type { AssistantMessage } from "../types.ts"; * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" * - OpenAI: "Your input exceeds the context window of this model" * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" + * - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)." * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" * - Groq: "Please reduce the length of the messages or completion" * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "the request exceeds the available context size, try increasing it" * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" @@ -35,11 +37,12 @@ const OVERFLOW_PATTERNS = [ /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) /input is too long for requested model/i, // Amazon Bedrock /exceeds the context window/i, // OpenAI (Completions & Responses API) - /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM) /input token count.*exceeds the maximum/i, // Google (Gemini) /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq - /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI /exceeds the limit of \d+/i, // GitHub Copilot /exceeds the available context size/i, // llama.cpp server @@ -83,13 +86,14 @@ const NON_OVERFLOW_PATTERNS = [ * * **Reliable detection (returns error with detectable message):** * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" - * - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens" + * - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)" * - Google Gemini: "input token count exceeds the maximum" * - xAI (Grok): "maximum prompt length is X but request contains Y" * - Groq: "reduce the length of the messages" * - Cerebras: 400/413 status code (no body) * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" - * - OpenRouter (all backends): "maximum context length is X tokens" + * - OpenRouter (most backends): "maximum context length is X tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "exceeds the available context size" * - LM Studio: "greater than the context length" diff --git a/packages/ai/src/utils/provider-env.ts b/packages/ai/src/utils/provider-env.ts new file mode 100644 index 00000000..db496067 --- /dev/null +++ b/packages/ai/src/utils/provider-env.ts @@ -0,0 +1,52 @@ +import type { ProviderEnv } from "../types.ts"; + +let procEnvCache: Map | null = null; + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802. + * Bun compiled binaries can expose an empty process.env inside Linux sandboxes + * even though /proc/self/environ contains the environment. + * + * This intentionally duplicates restoreSandboxEnv() in + * packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be + * used directly, without going through that entrypoint, so provider env lookup + * must not depend on process.env having been patched. + */ +function getBunSandboxEnvValue(name: string): string | undefined { + if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) { + return undefined; + } + + if (procEnvCache === null) { + procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as { + readFileSync(path: string, encoding: BufferEncoding): string; + }; + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not exist or may not be readable. + } + } + + return procEnvCache.get(name); +} + +/** + * Resolve a provider env value from scoped overrides, normal process.env, then + * the duplicated Bun sandbox fallback for direct pi-ai consumers. + */ +export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined { + return ( + env?.[name] || + (typeof process !== "undefined" ? process.env[name] : undefined) || + getBunSandboxEnvValue(name) || + undefined + ); +} diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 27c274aa..303044ad 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete, stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, stream } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts new file mode 100644 index 00000000..18023e11 --- /dev/null +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { getModels, getProviders } from "../src/models.ts"; +import type { Api, Model } from "../src/types.ts"; + +const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-fable-5", + "anthropic/claude-opus-4-8", + "cloudflare-ai-gateway/claude-fable-5", + "opencode/claude-opus-4-8", + "vercel-ai-gateway/anthropic/claude-opus-4.8", +]; + +function getAllModels(): Model[] { + return getProviders().flatMap((provider) => getModels(provider) as Model[]); +} + +describe("Anthropic adaptive thinking model metadata", () => { + it("marks built-in Anthropic Messages models that use adaptive thinking", () => { + const flaggedModels = getAllModels() + .filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages") + .filter((model) => model.compat?.forceAdaptiveThinking === true) + .map((model) => `${model.provider}/${model.id}`) + .sort(); + + expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); + expect(flaggedModels).toEqual( + flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)), + ); + }); +}); diff --git a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts new file mode 100644 index 00000000..f9523b40 --- /dev/null +++ b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts @@ -0,0 +1,86 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context } from "../src/types.ts"; + +function createSseResponse(events: Array<{ event: string; data: string }>): Response { + const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +function createFakeAnthropicClient(response: Response): Anthropic { + return { + messages: { create: () => ({ asResponse: async () => response }) }, + } as unknown as Anthropic; +} + +function eventsWithCacheCreation( + cacheCreation: Record | undefined, +): Array<{ event: string; data: string }> { + const startUsage: Record = { + input_tokens: 100, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }; + if (cacheCreation) startUsage.cache_creation = cacheCreation; + return [ + { + event: "message_start", + data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }), + }, + { + event: "content_block_start", + data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + }, + { + event: "content_block_delta", + data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }), + }, + { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 100, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]; +} + +// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10. +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }; + +describe("Anthropic 1h cache write cost", () => { + it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse( + eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }), + ); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h).toBe(400_000); + // 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75 + expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10); + }); + + it("falls back to the 5m rate when no breakdown is reported", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse(eventsWithCacheCreation(undefined)); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h ?? 0).toBe(0); + // 1M * 6.25/Mtok = 6.25 + expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10); + }); +}); diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index c6df9c3e..22ac0e59 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -12,8 +12,8 @@ interface CapturedRequest { function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { return { - id: "claude-opus-4-7", - name: "Claude Opus 4.7", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", api: "anthropic-messages", provider: "test-anthropic", baseUrl, @@ -22,7 +22,7 @@ function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["comp cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 32000, - compat, + compat: { forceAdaptiveThinking: true, ...compat }, }; } diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts index 2483e1a4..a9862485 100644 --- a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { complete } from "../src/index.ts"; import { getModels, getProviders } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts new file mode 100644 index 00000000..5663e2a8 --- /dev/null +++ b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; +import type { AssistantMessage, Context, Model } from "../src/types.ts"; + +interface AnthropicPayload { + messages?: Array<{ + role: string; + content: Array<{ type: string; text?: string; thinking?: string; signature?: string }>; + }>; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeModel(allowEmptySignature?: boolean): Model<"anthropic-messages"> { + return { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "http://127.0.0.1:9/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 1024, + ...(allowEmptySignature === undefined ? {} : { compat: { allowEmptySignature } }), + }; +} + +function makeContext(thinkingSignature: string): Context { + const assistant: AssistantMessage = { + role: "assistant", + content: [{ type: "thinking", thinking: "internal reasoning", thinkingSignature }], + provider: "xiaomi-token-plan-ams", + api: "anthropic-messages", + model: "mimo-v2.5-pro", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; + return { + messages: [ + { role: "user", content: "first", timestamp: Date.now() }, + assistant, + { role: "user", content: "second", timestamp: Date.now() }, + ], + }; +} + +async function capturePayload(model: Model<"anthropic-messages">, context: Context): Promise { + let capturedPayload: AnthropicPayload | undefined; + const stream = streamSimple(model, context, { + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicPayload; + throw new PayloadCaptured(); + }, + }); + await stream.result(); + if (!capturedPayload) throw new Error("Expected payload capture before request"); + return capturedPayload; +} + +describe("Anthropic empty thinking signature compat", () => { + it("converts empty-signature thinking to text by default", async () => { + const payload = await capturePayload(makeModel(), makeContext("")); + const assistant = payload.messages?.find((message) => message.role === "assistant"); + expect(assistant?.content).toEqual([{ type: "text", text: "internal reasoning" }]); + }); + + it("preserves empty-signature thinking when allowEmptySignature is enabled", async () => { + const payload = await capturePayload(makeModel(true), makeContext(" ")); + const assistant = payload.messages?.find((message) => message.role === "assistant"); + expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]); + }); +}); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts new file mode 100644 index 00000000..cef2ca48 --- /dev/null +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; +import { getModel } from "../src/models.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; + +interface AnthropicThinkingPayload { + thinking?: { type: string; budget_tokens?: number; display?: string }; + output_config?: { effort?: string }; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + // Id intentionally does not match any built-in adaptive substring. This + // mirrors corporate proxy schemes such as `anthropic--claude-opus-latest`. + id: "vendor--claude-opus-latest", + name: "Vendor Proxy Opus Latest", + api: "anthropic-messages", + provider: "vendor-proxy", + baseUrl: "http://127.0.0.1:9", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: AnthropicThinkingPayload | undefined; + + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicThinkingPayload; + throw new PayloadCaptured(); + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Anthropic forceAdaptiveThinking compat override", () => { + it("sends legacy thinking payload for custom model ids by default", async () => { + const payload = await capturePayload(makeCustomModel(), { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("sends adaptive thinking payload when compat.forceAdaptiveThinking is true", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }), { reasoning: "medium" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "medium" }); + }); + + it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + }); + + it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { + const model: Model<"anthropic-messages"> = { + ...getModel("anthropic", "claude-opus-4-8"), + compat: { forceAdaptiveThinking: false }, + }; + const payload = await capturePayload(model, { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("preserves thinking.type=disabled when reasoning is off regardless of override", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true })); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); +}); diff --git a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts index 2b7667d7..0c9d590f 100644 --- a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts +++ b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { complete } from "../src/index.ts"; import { getModels, getProviders } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts similarity index 89% rename from packages/ai/test/anthropic-opus-4-7-smoke.test.ts rename to packages/ai/test/anthropic-opus-4-8-smoke.test.ts index 7314f8af..0c1288b3 100644 --- a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts +++ b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; interface AnthropicThinkingPayload { @@ -22,9 +22,9 @@ function makeContext(): Context { }; } -describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () => { - it("streams Claude Opus 4.7 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => { - const model = getModel("anthropic", "claude-opus-4-7"); +describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => { + it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => { + const model = getModel("anthropic", "claude-opus-4-8"); let capturedPayload: AnthropicThinkingPayload | undefined; const s = streamSimple(model, makeContext(), { reasoning: "high", @@ -53,12 +53,12 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () = const thinkingBlock = response.content.find((block) => block.type === "thinking"); expect(thinkingBlock?.type).toBe("thinking"); if (!thinkingBlock || thinkingBlock.type !== "thinking") { - throw new Error("Expected thinking block from Claude Opus 4.7"); + throw new Error("Expected thinking block from Claude Opus 4.8"); } expect(typeof thinkingBlock.thinkingSignature).toBe("string"); const thinkingSignature = thinkingBlock.thinkingSignature; if (!thinkingSignature) { - throw new Error("Expected thinking signature from Claude Opus 4.7"); + throw new Error("Expected thinking signature from Claude Opus 4.8"); } expect(thinkingSignature.length).toBeGreaterThan(0); diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 03024966..d8daf7f7 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -166,6 +166,64 @@ describe("Anthropic raw SSE parsing", () => { }); }); + it("preserves refusal stop details from message_delta", async () => { + const model = getModel("anthropic", "claude-fable-5"); + const context: Context = { + messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }], + }; + const explanation = + "This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude."; + const response = createSseResponse([ + { + event: "message_start", + data: JSON.stringify({ + type: "message_start", + message: { + id: "msg_01XFUDYJgAACzvnptvVoYEL", + usage: { + input_tokens: 412, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }), + }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { + stop_reason: "refusal", + stop_details: { + type: "refusal", + category: "cyber", + explanation, + }, + }, + usage: { + input_tokens: 412, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + }, + { + event: "message_stop", + data: JSON.stringify({ type: "message_stop" }), + }, + ]); + + const stream = streamAnthropic(model, context, { + client: createFakeAnthropicClient(response), + }); + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe(explanation); + }); + it("ignores unknown SSE events after message_stop", async () => { const model = getModel("anthropic", "claude-haiku-4-5"); const context: Context = { diff --git a/packages/ai/test/anthropic-temperature-compat.test.ts b/packages/ai/test/anthropic-temperature-compat.test.ts new file mode 100644 index 00000000..06a05698 --- /dev/null +++ b/packages/ai/test/anthropic-temperature-compat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; +import { getModel } from "../src/models.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; + +interface AnthropicTemperaturePayload { + temperature?: number; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + id: "vendor--claude-opus-4-7", + name: "Vendor Proxy Opus 4.7", + api: "anthropic-messages", + provider: "vendor-proxy", + baseUrl: "http://127.0.0.1:9", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: AnthropicTemperaturePayload | undefined; + + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicTemperaturePayload; + throw new PayloadCaptured(); + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Anthropic temperature compatibility", () => { + it("omits temperature for Claude Opus 4.7", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("omits temperature for Claude Opus 4.8", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("omits default temperature for Claude Opus 4.7", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 1 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("keeps temperature for Claude Opus 4.6", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), { temperature: 0 }); + + expect(payload.temperature).toBe(0); + }); + + it("keeps temperature for Claude Sonnet 4.6", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-6"), { temperature: 0 }); + + expect(payload.temperature).toBe(0); + }); + + it("omits temperature for custom models with supportsTemperature disabled", async () => { + const payload = await capturePayload(makeCustomModel({ supportsTemperature: false }), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); +}); diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index ea53043c..581d3a34 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface AnthropicThinkingPayload { @@ -125,22 +125,29 @@ describe("Anthropic thinking disable payload", () => { expect(payload.output_config).toBeUndefined(); }); - it("sends thinking.type=disabled for Claude Opus 4.7 when thinking is off", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7")); + it("sends thinking.type=disabled for Claude Opus 4.8 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8")); expect(payload.thinking).toEqual({ type: "disabled" }); expect(payload.output_config).toBeUndefined(); }); - it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "high" }); + it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5")); + + expect(payload.thinking).toBeUndefined(); + expect(payload.output_config).toBeUndefined(); + }); + + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" }); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); }); - it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "xhigh" }); + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" }); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "xhigh" }); diff --git a/packages/ai/test/anthropic-tool-name-normalization.test.ts b/packages/ai/test/anthropic-tool-name-normalization.test.ts index bb454081..d52ff50e 100644 --- a/packages/ai/test/anthropic-tool-name-normalization.test.ts +++ b/packages/ai/test/anthropic-tool-name-normalization.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Tool } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 530c5f47..15b8a528 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -13,6 +13,7 @@ interface CapturedAzureClientOptions { interface CapturedAzureResponsesPayload { prompt_cache_key?: string; + store?: boolean; } const azureMock = vi.hoisted(() => ({ @@ -144,6 +145,16 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64)); }); + it("disables server-side response storage", async () => { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + }).result(); + + expect(azureMock.lastParams?.store).toBe(false); + }); + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; const model = getModel("azure-openai-responses", "gpt-4o-mini"); diff --git a/packages/ai/test/base-entrypoint.test.ts b/packages/ai/test/base-entrypoint.test.ts new file mode 100644 index 00000000..d54fa492 --- /dev/null +++ b/packages/ai/test/base-entrypoint.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { clearApiProviders, complete, getApiProvider, getApiProviders } from "../src/base.ts"; +import { register as registerAmazonBedrock } from "../src/providers/amazon-bedrock.ts"; +import { register as registerAnthropic } from "../src/providers/anthropic.ts"; +import { register as registerAzureOpenAIResponses } from "../src/providers/azure-openai-responses.ts"; +import { fauxAssistantMessage, registerFauxProvider } from "../src/providers/faux.ts"; +import { register as registerGoogle } from "../src/providers/google.ts"; +import { register as registerGoogleVertex } from "../src/providers/google-vertex.ts"; +import { register as registerMistral } from "../src/providers/mistral.ts"; +import { register as registerOpenAICodexResponses } from "../src/providers/openai-codex-responses.ts"; +import { register as registerOpenAICompletions } from "../src/providers/openai-completions.ts"; +import { register as registerOpenAIResponses } from "../src/providers/openai-responses.ts"; + +const registrations = [ + ["bedrock-converse-stream", registerAmazonBedrock], + ["anthropic-messages", registerAnthropic], + ["azure-openai-responses", registerAzureOpenAIResponses], + ["google-generative-ai", registerGoogle], + ["google-vertex", registerGoogleVertex], + ["mistral-conversations", registerMistral], + ["openai-codex-responses", registerOpenAICodexResponses], + ["openai-completions", registerOpenAICompletions], + ["openai-responses", registerOpenAIResponses], +] as const; + +afterEach(() => { + clearApiProviders(); +}); + +describe("base entrypoint", () => { + it("starts without built-in provider registrations", () => { + expect(getApiProviders()).toEqual([]); + }); + + it.each(registrations)("registers the %s transport explicitly", (api, register) => { + register(); + expect(getApiProvider(api)?.api).toBe(api); + }); + + it("dispatches custom providers", async () => { + const registration = registerFauxProvider(); + registration.setResponses([fauxAssistantMessage("hello")]); + const response = await complete(registration.getModel(), { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }); + expect(response.content).toEqual([{ type: "text", text: "hello" }]); + }); + + it("fails clearly when no transport is registered", async () => { + await expect( + complete( + { + id: "missing-model", + name: "Missing Model", + api: "missing-api", + provider: "missing-provider", + baseUrl: "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1, + maxTokens: 1, + }, + { messages: [] }, + ), + ).rejects.toThrow("No API provider registered for api: missing-api"); + }); +}); diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index d62abd1b..d74dedae 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -117,7 +117,7 @@ describe("bedrock convertMessages skips unknown content types", () => { expect(p.messages[0].content[0]).toEqual({ text: "hello" }); }); - it("skips user messages with only unknown content blocks", async () => { + it("replaces user messages with only unknown content blocks with a placeholder", async () => { const messages: Message[] = [ { role: "user", @@ -128,9 +128,95 @@ describe("bedrock convertMessages skips unknown content types", () => { const payload = await capturePayload({ messages }); expect(payload).toBeDefined(); const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("replaces blank user string content with a placeholder", async () => { + const payload = await capturePayload({ + messages: [{ role: "user", content: " ", timestamp: Date.now() }], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("filters blank user text blocks when other content remains", async () => { + const payload = await capturePayload({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "hello" }, + ], + timestamp: Date.now(), + }, + ], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "hello" }]); + }); + + it("replaces user content emptied by surrogate sanitization with a placeholder", async () => { + const payload = await capturePayload({ + messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("skips assistant text blocks emptied by surrogate sanitization", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [{ type: "text", text: String.fromCharCode(0xd83d) }], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; expect(p.messages).toHaveLength(0); }); + it("replaces blank tool result content with a placeholder", async () => { + const messages: Message[] = [ + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "tool", + content: [{ type: "text", text: "" }], + isError: false, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { + messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>; + }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "" }]); + }); + it("skips assistant messages with only unknown content blocks", async () => { const messages: Message[] = [ { diff --git a/packages/ai/test/bedrock-custom-headers.test.ts b/packages/ai/test/bedrock-custom-headers.test.ts new file mode 100644 index 00000000..1017d089 --- /dev/null +++ b/packages/ai/test/bedrock-custom-headers.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MiddlewareHandler = (next: (args: unknown) => Promise) => (args: unknown) => Promise; + +const bedrockMock = vi.hoisted(() => ({ + middlewareRegistrations: [] as Array<{ + handler: MiddlewareHandler; + opts: { step?: string; name?: string; priority?: string }; + }>, +})); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + middlewareStack = { + add: (handler: MiddlewareHandler, opts: { step?: string; name?: string; priority?: string }) => { + bedrockMock.middlewareRegistrations.push({ handler, opts }); + }, + }; + + send(): Promise { + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { getModel } from "../src/models.ts"; +import type { BedrockOptions } from "../src/providers/amazon-bedrock.ts"; +import { streamBedrock, streamSimpleBedrock } from "../src/providers/amazon-bedrock.ts"; +import type { Context, Model } from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +const MIDDLEWARE_NAME = "pi-ai-custom-headers"; + +function getModelFixture(): Model<"bedrock-converse-stream"> { + return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); +} + +/** + * Drive a stream to completion so the middleware (registered before `client.send`) + * is captured even though the mocked `send()` rejects. Errors are swallowed because + * the rejecting mock is expected — we only care about the recorded registrations. + */ +async function driveBedrock(options: BedrockOptions): Promise { + await streamBedrock(getModelFixture(), context, options) + .result() + .catch(() => undefined); +} + +function findCustomHeadersRegistration() { + const matches = bedrockMock.middlewareRegistrations.filter((r) => r.opts.name === MIDDLEWARE_NAME); + return matches; +} + +beforeEach(() => { + bedrockMock.middlewareRegistrations.length = 0; +}); + +describe("bedrock custom headers middleware", () => { + it("VC1: registers a build-step middleware that injects the caller header (happy path)", async () => { + await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } }); + + const registrations = findCustomHeadersRegistration(); + expect(registrations).toHaveLength(1); + + const [reg] = registrations; + expect(reg.opts.step).toBe("build"); + expect(reg.opts.priority).toBe("low"); + expect(reg.opts.name).toBe(MIDDLEWARE_NAME); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { request: { headers: {} as Record } }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers["x-custom"]).toBe("v"); + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(nextSpy).toHaveBeenCalledWith(fakeArgs); + }); + + it("VC2: skips reserved headers case-insensitively while applying allowed ones", async () => { + await driveBedrock({ + cacheRetention: "none", + headers: { + authorization: "evil", + "x-amz-date": "evil", + "x-allowed": "ok", + Authorization: "evil2", + "X-Amz-Date": "evil2", + HOST: "evil3", + }, + }); + + const [reg] = findCustomHeadersRegistration(); + expect(reg).toBeDefined(); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { + request: { + headers: { + authorization: "real-auth", + "x-amz-date": "real-date", + host: "real-host", + } as Record, + }, + }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers.authorization).toBe("real-auth"); + expect(fakeArgs.request.headers["x-amz-date"]).toBe("real-date"); + expect(fakeArgs.request.headers.host).toBe("real-host"); + expect(fakeArgs.request.headers["x-allowed"]).toBe("ok"); + // Mixed-case reserved keys must be skipped too: a case-sensitive guard would + // add them back as distinct capitalised keys. Assert no such leak occurred and + // that the only new key beyond the three pre-existing ones is `x-allowed`. + expect(fakeArgs.request.headers.Authorization).toBeUndefined(); + expect(fakeArgs.request.headers["X-Amz-Date"]).toBeUndefined(); + expect(fakeArgs.request.headers.HOST).toBeUndefined(); + expect(Object.keys(fakeArgs.request.headers).sort()).toEqual( + ["authorization", "host", "x-allowed", "x-amz-date"].sort(), + ); + expect(nextSpy).toHaveBeenCalledTimes(1); + }); + + it("VC3: registers no middleware when headers is undefined", async () => { + await driveBedrock({ cacheRetention: "none" }); + + expect(findCustomHeadersRegistration()).toHaveLength(0); + }); + + it("VC3: registers no middleware when headers is empty", async () => { + await driveBedrock({ cacheRetention: "none", headers: {} }); + + expect(findCustomHeadersRegistration()).toHaveLength(0); + }); + + it("VC3 (structural guard): passes through unchanged when the request has no headers", async () => { + await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } }); + + const [reg] = findCustomHeadersRegistration(); + expect(reg).toBeDefined(); + + const nextSpy = vi.fn(async (a: unknown) => a); + + const argsNoHeaders = { request: {} }; + await expect(reg.handler(nextSpy)(argsNoHeaders)).resolves.toBeDefined(); + expect(nextSpy).toHaveBeenCalledWith(argsNoHeaders); + + const argsUndefinedRequest = { request: undefined }; + await expect(reg.handler(nextSpy)(argsUndefinedRequest)).resolves.toBeDefined(); + expect(nextSpy).toHaveBeenCalledWith(argsUndefinedRequest); + + expect(nextSpy).toHaveBeenCalledTimes(2); + }); + + it("VC4: streamSimpleBedrock forwards headers end-to-end (regression guard)", async () => { + await streamSimpleBedrock(getModelFixture(), context, { headers: { "x-custom": "v" } }) + .result() + .catch(() => undefined); + + const registrations = findCustomHeadersRegistration(); + expect(registrations).toHaveLength(1); + + const [reg] = registrations; + expect(reg.opts.step).toBe("build"); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { request: { headers: {} as Record } }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers["x-custom"]).toBe("v"); + }); +}); diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index a45bc6cf..18be2476 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -45,7 +45,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }); import { getModel } from "../src/models.ts"; -import { streamBedrock } from "../src/providers/amazon-bedrock.ts"; +import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.ts"; import type { Context, Model } from "../src/types.ts"; const context: Context = { @@ -83,8 +83,12 @@ afterEach(() => { } }); -async function captureClientConfig(model: Model<"bedrock-converse-stream">): Promise> { - await streamBedrock(model, context, { cacheRetention: "none" }).result(); +async function captureClientConfig( + model: Model<"bedrock-converse-stream">, + options: BedrockOptions = {}, +): Promise> { + bedrockMock.constructorCalls.length = 0; + await streamBedrock(model, context, { cacheRetention: "none", ...options }).result(); expect(bedrockMock.constructorCalls).toHaveLength(1); return bedrockMock.constructorCalls[0]; } @@ -98,7 +102,7 @@ describe("bedrock endpoint resolution", () => { it("does not pin standard AWS endpoints when AWS_REGION is configured", async () => { process.env.AWS_REGION = "us-east-2"; - const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); const config = await captureClientConfig(model); @@ -115,9 +119,32 @@ describe("bedrock endpoint resolution", () => { expect(config.region).toBe("eu-central-1"); }); + it("handles missing regions for explicit, scoped, and ambient profiles", async () => { + const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"); + + let config = await captureClientConfig(model, { profile: "bedrock-profile" }); + + expect(config.profile).toBe("bedrock-profile"); + expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + expect(config.region).toBe("eu-central-1"); + + config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-bedrock-profile" } }); + + expect(config.profile).toBe("scoped-bedrock-profile"); + expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + expect(config.region).toBe("eu-central-1"); + + process.env.AWS_PROFILE = "ambient-bedrock-profile"; + config = await captureClientConfig(model); + + expect(config.profile).toBe("ambient-bedrock-profile"); + expect(config.endpoint).toBeUndefined(); + expect(config.region).toBeUndefined(); + }); + it("still passes custom Bedrock endpoints through to the SDK client", async () => { process.env.AWS_REGION = "us-west-2"; - const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, baseUrl: "https://bedrock-vpc.example.com", @@ -128,4 +155,30 @@ describe("bedrock endpoint resolution", () => { expect(config.endpoint).toBe("https://bedrock-vpc.example.com"); expect(config.region).toBe("us-west-2"); }); + + it("extracts region from inference profile ARN regardless of AWS_REGION", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-west-2"); + }); + + it("extracts region from GovCloud inference profile ARN", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-gov-west-1"); + }); }); diff --git a/packages/ai/test/bedrock-models.test.ts b/packages/ai/test/bedrock-models.test.ts index 2cfd8fb9..5bd5a1f3 100644 --- a/packages/ai/test/bedrock-models.test.ts +++ b/packages/ai/test/bedrock-models.test.ts @@ -17,8 +17,8 @@ */ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModels } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; import { hasBedrockCredentials } from "./bedrock-utils.ts"; diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 46a5277b..8f4e06e7 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -53,12 +53,12 @@ async function capturePayload( } describe("Bedrock thinking payload", () => { - it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model); @@ -68,12 +68,12 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); - it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model, { reasoning: "xhigh" }); @@ -83,6 +83,25 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); + it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model, { reasoning: "xhigh" }); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); + }); + it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => { const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); const model: Model<"bedrock-converse-stream"> = { @@ -101,8 +120,8 @@ describe("Bedrock thinking payload", () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model, { region: "us-gov-west-1" }); diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 8828ba86..b5c55198 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; +import { MODELS } from "../src/models.generated.ts"; import { getModel } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; import { streamOpenAIResponses } from "../src/providers/openai-responses.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; class PayloadCaptured extends Error { @@ -13,6 +14,11 @@ class PayloadCaptured extends Error { } } +interface OpenAICompletionsCachePayload { + prompt_cache_key?: string; + prompt_cache_retention?: string; +} + function stopAfterPayload(capture: (payload: TPayload) => void): (payload: unknown) => never { return (payload: unknown): never => { capture(payload as TPayload); @@ -455,5 +461,39 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { expect(capturedPayload.prompt_cache_key).toBeUndefined(); expect(capturedPayload.prompt_cache_retention).toBeUndefined(); }); + + it.each([ + MODELS.opencode["deepseek-v4-flash"], + MODELS.opencode["deepseek-v4-pro"], + MODELS.opencode["kimi-k2.5"], + MODELS.opencode["kimi-k2.6"], + MODELS.opencode["minimax-m2.7"], + MODELS["opencode-go"]["kimi-k2.6"], + ] as const)("should omit long cache retention for $provider/$id", async (metadata) => { + const model = metadata as Model<"openai-completions">; + let capturedPayload: OpenAICompletionsCachePayload | undefined; + + try { + const s = streamOpenAICompletions(model, context, { + apiKey: "fake-key", + cacheRetention: "long", + sessionId: "session-opencode-long-cache-unsupported", + onPayload: stopAfterPayload((payload) => { + capturedPayload = payload; + }), + }); + + for await (const event of s) { + if (event.type === "error") break; + } + } catch { + // Expected to fail + } + + expect(model.compat?.supportsLongCacheRetention).toBe(false); + expect(capturedPayload).toBeDefined(); + expect(capturedPayload?.prompt_cache_key).toBeUndefined(); + expect(capturedPayload?.prompt_cache_retention).toBeUndefined(); + }); }); }); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index e00764bc..54087a29 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,8 +14,8 @@ import type { ChildProcess } from "child_process"; import { execSync, spawn } from "child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; +import { complete } from "../src/index.ts"; +import { getModel, getModels } from "../src/models.ts"; import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts"; import { isContextOverflow } from "../src/utils/overflow.ts"; import { hasAzureOpenAICredentials } from "./azure-utils.ts"; @@ -120,15 +120,15 @@ describe("Context overflow error handling", () => { // ============================================================================= // GitHub Copilot (OAuth) - // Tests both OpenAI and Anthropic models via Copilot + // Tests both Google and Anthropic models via Copilot // ============================================================================= describe("GitHub Copilot (OAuth)", () => { - // OpenAI model via Copilot + // Google model via Copilot it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should detect overflow via isContextOverflow", + "gemini-2.5-pro - should detect overflow via isContextOverflow", async () => { - const model = getModel("github-copilot", "claude-sonnet-4.6"); + const model = getModel("github-copilot", "gemini-2.5-pro"); const result = await testContextOverflow(model, githubCopilotToken!); logResult(result); @@ -297,8 +297,15 @@ describe("Context overflow error handling", () => { // ============================================================================= describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { - it("gpt-oss-120b - should detect overflow via isContextOverflow", async () => { - const model = getModel("cerebras", "gpt-oss-120b"); + it("available model - should detect overflow via isContextOverflow", async () => { + const preferredCerebrasModelIds: string[] = ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"]; + const cerebrasModels = getModels("cerebras"); + const model = + cerebrasModels.find((candidate) => preferredCerebrasModelIds.includes(candidate.id)) ?? cerebrasModels[0]; + if (!model) { + throw new Error("No Cerebras models available"); + } + const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!); logResult(result); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 23593394..73fac229 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -25,8 +25,8 @@ import { writeFileSync } from "fs"; import { Type } from "typebox"; import { beforeAll, describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple, getEnvApiKey } from "../src/stream.ts"; import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.ts"; import { hasAzureOpenAICredentials } from "./azure-utils.ts"; import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index bb8e7bfb..fadd67a9 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -630,37 +630,37 @@ describe("AI Providers Empty Message Tests", () => { describe("GitHub Copilot Provider Empty Messages", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle empty content array", + "claude-haiku-4.5 - should handle empty content array", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle empty string content", + "claude-haiku-4.5 - should handle empty string content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyStringMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle whitespace-only content", + "claude-haiku-4.5 - should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle empty assistant message in conversation", + "claude-haiku-4.5 - should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/env-api-keys.test.ts b/packages/ai/test/env-api-keys.test.ts index 61fec912..b7c86432 100644 --- a/packages/ai/test/env-api-keys.test.ts +++ b/packages/ai/test/env-api-keys.test.ts @@ -4,6 +4,7 @@ import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN; const originalGhToken = process.env.GH_TOKEN; const originalGitHubToken = process.env.GITHUB_TOKEN; +const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY; afterEach(() => { if (originalCopilotGitHubToken === undefined) { @@ -23,6 +24,12 @@ afterEach(() => { } else { process.env.GITHUB_TOKEN = originalGitHubToken; } + + if (originalZaiCodingCnApiKey === undefined) { + delete process.env.ZAI_CODING_CN_API_KEY; + } else { + process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey; + } }); describe("environment API keys", () => { @@ -43,4 +50,11 @@ describe("environment API keys", () => { expect(findEnvKeys("github-copilot")).toEqual(["COPILOT_GITHUB_TOKEN"]); expect(getEnvApiKey("github-copilot")).toBe("copilot-token"); }); + + it("resolves ZAI China Coding Plan credentials from ZAI_CODING_CN_API_KEY", () => { + process.env.ZAI_CODING_CN_API_KEY = "zai-coding-cn-token"; + + expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]); + expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token"); + }); }); diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 5539cdd3..8b291b89 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; -import { getModel } from "../src/models.ts"; +import { getModel, getModels } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import type { Context, Model, Tool } from "../src/types.ts"; @@ -38,12 +38,14 @@ describe("Fireworks models", () => { }); it("registers the Fire Pass turbo router model", () => { - const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p6-turbo"); + const model = getModels("fireworks").find( + (candidate) => candidate.id.startsWith("accounts/fireworks/routers/") && candidate.id.endsWith("-turbo"), + ); expect(model).toBeDefined(); - expect(model.api).toBe("anthropic-messages"); - expect(model.baseUrl).toBe("https://api.fireworks.ai/inference"); - expect(model.input).toEqual(["text", "image"]); + expect(model?.api).toBe("anthropic-messages"); + expect(model?.baseUrl).toBe("https://api.fireworks.ai/inference"); + expect(model?.input).toEqual(["text", "image"]); }); it("resolves FIREWORKS_API_KEY from the environment", () => { @@ -95,8 +97,8 @@ function createFireworksModel(compat?: Model<"anthropic-messages">["compat"]): M function createAnthropicModel(): Model<"anthropic-messages"> { return { - id: "claude-opus-4-7", - name: "Claude Opus 4.7", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", api: "anthropic-messages", provider: "anthropic", baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index ee3de3e8..ace2bd8f 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.ts"; +import { getModel, getSupportedThinkingLevels } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import type { Context } from "../src/types.ts"; @@ -54,6 +54,16 @@ describe("Copilot Claude via Anthropic Messages", () => { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], }; + it("applies Copilot-specific adaptive thinking effort overrides", () => { + const opus47 = getModel("github-copilot", "claude-opus-4.7"); + expect(opus47.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh" }); + expect(getSupportedThinkingLevels(opus47)).toContain("xhigh"); + + const sonnet46 = getModel("github-copilot", "claude-sonnet-4.6"); + expect(sonnet46.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "max" }); + expect(getSupportedThinkingLevels(sonnet46)).toContain("xhigh"); + }); + it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => { const model = getModel("github-copilot", "claude-sonnet-4.6"); expect(model.api).toBe("anthropic-messages"); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 085b1cf0..2aba3084 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.ts"; +import { getModels } from "../src/models.ts"; +import { + githubCopilotOAuthProvider, + loginGitHubCopilot, + refreshGitHubCopilotToken, +} from "../src/utils/oauth/github-copilot.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -29,7 +34,212 @@ describe("GitHub Copilot OAuth device flow", () => { vi.useRealTimers(); }); - it("waits before the first poll and increases the safety margin after slow_down", async () => { + it("filters models to the authenticated account picker catalog", async () => { + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url === "https://api.individual.githubcopilot.com/models") { + expect(init?.headers).toMatchObject({ + Authorization: "Bearer tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + }); + return jsonResponse({ + data: [ + { + id: "gpt-4.1", + model_picker_enabled: true, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "claude-opus-4.7", + model_picker_enabled: true, + policy: { state: "disabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4-nano", + model_picker_enabled: false, + capabilities: { supports: { tool_calls: true } }, + }, + ], + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const credentials = await refreshGitHubCopilotToken("ghu_refresh_token"); + expect(credentials.availableModelIds).toEqual(["gpt-4.1"]); + + const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? []; + expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([ + "gpt-4.1", + ]); + }); + + it("reports device-code details through onDeviceCode", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + const loginPromise = loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(onDeviceCode).toHaveBeenCalledWith({ + userCode: "ABCD-EFGH", + verificationUri: "https://github.com/login/device", + intervalSeconds: 1, + expiresInSeconds: 900, + }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + }); + + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { + // A malicious enterprise OAuth server could return a verification_uri that + // the browser launcher would otherwise hand to the OS. Ensure such values + // are rejected at the deserialization boundary. + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "$(id>/tmp/pwned)", + interval: 1, + expires_in: 900, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + await expect( + loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }), + ).rejects.toThrow(/Untrusted verification_uri/); + expect(onDeviceCode).not.toHaveBeenCalled(); + }); + + it("normalizes verification_uri before it reaches onDeviceCode", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const rawVerificationUri = "https://github.com/login/\x1b]8;;evil"; + const normalizedVerificationUri = new URL(rawVerificationUri).href; + expect(normalizedVerificationUri).not.toBe(rawVerificationUri); + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: rawVerificationUri, + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + const loginPromise = loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(onDeviceCode).toHaveBeenCalledWith({ + userCode: "ABCD-EFGH", + verificationUri: normalizedVerificationUri, + intervalSeconds: 1, + expiresInSeconds: 900, + }); + expect(onDeviceCode).not.toHaveBeenCalledWith(expect.objectContaining({ verificationUri: rawVerificationUri })); + + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + }); + + it("polls immediately and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -37,7 +247,7 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ jsonResponse({ error: "authorization_pending", error_description: "pending" }), - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), jsonResponse({ access_token: "ghu_refresh_token" }), ]; @@ -85,6 +295,10 @@ describe("GitHub Copilot OAuth device flow", () => { }); } + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + if (url.includes("/models/") && url.endsWith("/policy")) { return new Response("", { status: 200 }); } @@ -95,48 +309,42 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", onProgress: () => {}, }); await vi.advanceTimersByTimeAsync(0); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(5999); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); - await vi.advanceTimersByTimeAsync(5999); + await vi.advanceTimersByTimeAsync(4999); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(2); - await vi.advanceTimersByTimeAsync(13999); + await vi.advanceTimersByTimeAsync(9999); expect(accessTokenPollTimes).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); await loginPromise; expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 12000, - startTime.getTime() + 26000, + startTime.getTime(), + startTime.getTime() + 5000, + startTime.getTime() + 15000, ]); }); - it("uses the remaining lifetime for a final poll before timing out after repeated slow_down responses", async () => { + it("times out after repeated slow_down responses", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), - jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), + jsonResponse({ error: "slow_down", error_description: "still too fast" }), jsonResponse({ error: "authorization_pending", error_description: "pending" }), ]; @@ -168,29 +376,25 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", }); const rejection = expect(loginPromise).rejects.toThrow( /Device flow timed out after one or more slow_down responses/, ); - await vi.advanceTimersByTimeAsync(6000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime()]); - await vi.advanceTimersByTimeAsync(14000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); - await vi.advanceTimersByTimeAsync(4999); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(14999); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); await vi.advanceTimersByTimeAsync(1); await rejection; - expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 20000, - startTime.getTime() + 25000, - ]); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); }); }); diff --git a/packages/ai/test/google-thinking-disable.test.ts b/packages/ai/test/google-thinking-disable.test.ts index 30df3638..3920f4c8 100644 --- a/packages/ai/test/google-thinking-disable.test.ts +++ b/packages/ai/test/google-thinking-disable.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.ts"; type SimpleOptionsWithExtras = SimpleStreamOptions & Record; diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index b832379f..a752b7ce 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -443,19 +443,19 @@ describe("Tool Results with Images", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle tool result with only image", + "claude-haiku-4.5 - should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await handleToolWithImageResult(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle tool result with text and image", + "claude-haiku-4.5 - should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/interleaved-thinking.test.ts b/packages/ai/test/interleaved-thinking.test.ts index 4cf387ce..a2d3f5a6 100644 --- a/packages/ai/test/interleaved-thinking.test.ts +++ b/packages/ai/test/interleaved-thinking.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { completeSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple } from "../src/stream.ts"; import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts"; import { StringEnum } from "../src/utils/typebox-helpers.ts"; import { hasBedrockCredentials } from "./bedrock-utils.ts"; diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index e21f0d12..14bccff0 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href; +const baseEntryUrl = new URL("../src/base.ts", import.meta.url).href; const SDK_SPECIFIERS = [ "@anthropic-ai/sdk", @@ -16,9 +17,10 @@ const SDK_SPECIFIERS = [ type ProbeResult = { loadedSpecifiers: string[]; + value?: unknown; }; -function runProbe(action: string): ProbeResult { +function runProbe(action: string, entryUrl = aiEntryUrl): ProbeResult { const script = ` import { registerHooks } from "node:module"; @@ -34,9 +36,11 @@ function runProbe(action: string): ProbeResult { }, }); - const mod = await import(${JSON.stringify(aiEntryUrl)}); - ${action} - console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)] })); + const mod = await import(${JSON.stringify(entryUrl)}); + const value = await (async () => { + ${action} + })(); + console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)], value })); `; const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], { @@ -66,6 +70,33 @@ describe("lazy provider module loading", () => { expect(result.loadedSpecifiers).toEqual([]); }); + it("registers built-in transports when importing the root barrel", () => { + const result = runProbe(`return mod.getApiProviders().map((provider) => provider.api).sort();`); + expect(result.value).toEqual([ + "anthropic-messages", + "azure-openai-responses", + "bedrock-converse-stream", + "google-generative-ai", + "google-vertex", + "mistral-conversations", + "openai-codex-responses", + "openai-completions", + "openai-responses", + ]); + }); + + it("registers built-in image transports when importing the root barrel", () => { + const result = runProbe(`return mod.getImagesApiProvider("openrouter-images")?.api;`); + expect(result.loadedSpecifiers).toEqual([]); + expect(result.value).toBe("openrouter-images"); + }); + + it("does not load provider SDKs or register transports when importing the base barrel", () => { + const result = runProbe(`return mod.getApiProviders().map((provider) => provider.api);`, baseEntryUrl); + expect(result.loadedSpecifiers).toEqual([]); + expect(result.value).toEqual([]); + }); + it("loads only the Anthropic SDK when calling the root lazy wrapper", () => { const result = runProbe(` const model = { @@ -96,4 +127,17 @@ describe("lazy provider module loading", () => { expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]); }); + + it("dispatches through a lazy wrapper again after resetting providers", () => { + const result = runProbe(` + const model = mod.getModel("anthropic", "claude-sonnet-4-6"); + const context = { messages: [{ role: "user", content: "hi" }] }; + await mod.streamSimple(model, context).result(); + mod.resetApiProviders(); + return (await mod.streamSimple(model, context).result()).stopReason; + `); + + expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]); + expect(result.value).toBe("error"); + }); }); diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts index 35a5b96b..0e2f45c5 100644 --- a/packages/ai/test/mistral-reasoning-mode.test.ts +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface MistralPayload { promptMode?: "reasoning"; reasoningEffort?: "none" | "high"; + promptCacheKey?: string; } function makeContext(): Context { @@ -77,4 +78,21 @@ describe("Mistral reasoning mode selection", () => { expect(payload.reasoningEffort).toBeUndefined(); expect(payload.promptMode).toBeUndefined(); }); + + it("uses the session id as prompt cache key", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), { + sessionId: "session-123", + }); + + expect(payload.promptCacheKey).toBe("session-123"); + }); + + it("omits prompt cache key when cache retention is disabled", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), { + sessionId: "session-123", + cacheRetention: "none", + }); + + expect(payload.promptCacheKey).toBeUndefined(); + }); }); diff --git a/packages/ai/test/mistral-tool-schema.test.ts b/packages/ai/test/mistral-tool-schema.test.ts index c6898fc6..3c490c6d 100644 --- a/packages/ai/test/mistral-tool-schema.test.ts +++ b/packages/ai/test/mistral-tool-schema.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; interface MistralToolPayload { diff --git a/packages/ai/test/node-http-proxy.test.ts b/packages/ai/test/node-http-proxy.test.ts index f4c9b735..a077a928 100644 --- a/packages/ai/test/node-http-proxy.test.ts +++ b/packages/ai/test/node-http-proxy.test.ts @@ -54,6 +54,17 @@ describe("node HTTP proxy resolution", () => { ); }); + it("prefers scoped proxy env aliases before process env aliases", () => { + resetProxyEnv(); + process.env.https_proxy = "http://process-proxy.example:8080"; + + expect( + resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com", { + HTTPS_PROXY: "http://scoped-proxy.example:8080", + })?.toString(), + ).toBe("http://scoped-proxy.example:8080/"); + }); + it("rejects SOCKS and PAC proxy URLs explicitly", () => { resetProxyEnv(); process.env.HTTPS_PROXY = "socks5://proxy.example:1080"; diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts new file mode 100644 index 00000000..f32decb6 --- /dev/null +++ b/packages/ai/test/oauth-device-code.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts"; + +describe("OAuth device-code polling", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls immediately and returns the completed value", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const pollTimes: number[] = []; + const poll = vi.fn(async () => { + pollTimes.push(Date.now()); + return pollTimes.length === 1 + ? { status: "pending" as const } + : { status: "complete" as const, value: "token" }; + }); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 30, + poll, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); + + await vi.advanceTimersByTimeAsync(1999); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); + + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([ + new Date("2026-03-09T00:00:00Z").getTime(), + new Date("2026-03-09T00:00:02Z").getTime(), + ]); + }); + + it("cancels an in-flight wait", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 5, + expiresInSeconds: 30, + poll: async () => ({ status: "pending" }), + signal: controller.signal, + }); + + controller.abort(); + await expect(resultPromise).rejects.toThrow("Login cancelled"); + }); +}); diff --git a/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts b/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts index 0abb46f6..62245dcf 100644 --- a/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts +++ b/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 0157f6f6..820fbe6b 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,10 +1,429 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.ts"; +import { + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "../src/utils/oauth/openai-codex.ts"; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +function createAccessToken(accountId: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ + "https://api.openai.com/auth": { + chatgpt_account_id: accountId, + }, + }), + ).toString("base64"); + return `${header}.${payload}.signature`; +} + +function deviceAuthPendingResponse(): Response { + return jsonResponse( + { + error: { + message: "Device authorization is pending. Please try again.", + type: "invalid_request_error", + param: null, + code: "deviceauth_authorization_pending", + }, + }, + 403, + ); +} describe("OpenAI Codex OAuth", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("logs in with the OpenAI Codex device code flow", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-05-20T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessToken = createAccessToken("account-123"); + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + instructions?: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + const pollTimes: number[] = []; + const pollResponses = [ + deviceAuthPendingResponse(), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + }); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + + if (url === "https://auth.openai.com/oauth/token") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/x-www-form-urlencoded" }); + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("authorization_code"); + expect(params.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann"); + expect(params.get("code")).toBe("oauth-code"); + expect(params.get("redirect_uri")).toBe("https://auth.openai.com/deviceauth/callback"); + expect(params.get("code_verifier")).toBe("device-code-verifier"); + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: (info) => deviceInfos.push(info), + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(deviceInfos).toEqual([ + { + userCode: "ABCD-1234", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(4999); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(1); + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + expires: startTime.getTime() + 5000 + 3600 * 1000, + accountId: "account-123", + }); + expect(pollTimes).toEqual([startTime.getTime(), startTime.getTime() + 5000]); + }); + + it("offers browser login first and uses the selected OpenAI Codex device code flow", async () => { + const accessToken = createAccessToken("account-456"); + const selectPrompts: Array<{ + message: string; + options: Array<{ id: string; label: string }>; + }> = []; + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "WXYZ-7890", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }); + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => { + throw new Error("Browser login should not start"); + }, + onDeviceCode: (info) => deviceInfos.push(info), + onPrompt: async () => { + throw new Error("Prompt should not be used"); + }, + onSelect: async (prompt) => { + selectPrompts.push(prompt); + return "device_code"; + }, + }), + ).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-456", + }); + + expect(selectPrompts).toEqual([ + { + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }, + ]); + expect(deviceInfos).toEqual([ + { + userCode: "WXYZ-7890", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + }); + + it("cancels when OpenAI Codex login method selection is cancelled", async () => { + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }), + ).rejects.toThrow("Login cancelled"); + }); + + it("cancels the OpenAI Codex device code flow while waiting", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + signal: controller.signal, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + controller.abort(); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Login cancelled"); + }); + + it("times out the OpenAI Codex device code flow after 15 minutes", async () => { + vi.useFakeTimers(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "60", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Device flow timed out"); + }); + + it("treats OpenAI Codex device auth 403 and 404 responses as pending", async () => { + vi.useFakeTimers(); + const accessToken = createAccessToken("account-403-404"); + const pollTimes: number[] = []; + const pollResponses = [ + jsonResponse({ error: "access_denied", error_description: "denied" }, 403), + new Response("not ready", { status: 404, headers: { "Content-Type": "text/plain" } }), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "1", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-403-404", + }); + expect(pollTimes).toHaveLength(3); + }); + + it("includes the response body in OpenAI Codex device auth poll failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ error: "server_error", error_description: "try again later" }, 500); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }), + ).rejects.toThrow( + 'OpenAI Codex device auth failed with status 500: {"error":"server_error","error_description":"try again later"}', + ); }); it("does not write token refresh failures to stderr", async () => { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index e50111c9..fe4f3ea6 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -311,7 +311,182 @@ describe("openai-codex streaming", () => { expect(result.stopReason).toBe("length"); }); - it("sets session_id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { + it("aborts SSE fetch when response headers do not arrive", async () => { + vi.useFakeTimers(); + const token = mockToken(); + + const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + const signal = init?.signal; + if (!signal) { + throw new Error("Expected SSE fetch to receive an abort signal"); + } + + return new Promise((_, reject) => { + const onAbort = () => { + const reason = signal.reason; + reject(reason instanceof Error ? reason : new Error("SSE fetch aborted")); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + }).result(); + let settled = false; + const observedResultPromise = resultPromise.then((result) => { + settled = true; + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(10_000); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(10_000); + const result = await observedResultPromise; + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Codex SSE response headers timed out after 20000ms"); + }); + + it("aborts SSE body reads after response headers arrive", async () => { + const token = mockToken(); + const encoder = new TextEncoder(); + const timers: ReturnType[] = []; + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: string) => { + if (!cancelled) controller.enqueue(encoder.encode(chunk)); + }; + enqueue( + `${[ + `data: ${JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + })}`, + `data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`, + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "one" })}`, + ].join("\n\n")}\n\n`, + ); + timers.push( + setTimeout(() => { + enqueue(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "two" })}\n\n`); + }, 10), + ); + timers.push( + setTimeout(() => { + if (cancelled) return; + enqueue( + `${[ + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "onetwo" }], + }, + })}`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + })}`, + ].join("\n\n")}\n\n`, + ); + controller.close(); + }, 20), + ); + }, + cancel() { + cancelled = true; + for (const timer of timers) clearTimeout(timer); + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } })), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + const controller = new AbortController(); + const events: string[] = []; + + const resultStream = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + signal: controller.signal, + }); + for await (const event of resultStream) { + events.push(event.type === "text_delta" ? `text_delta:${event.delta}` : event.type); + if (event.type === "text_delta" && event.delta === "one") { + controller.abort(); + } + } + + const result = await resultStream.result(); + expect(result.stopReason).toBe("aborted"); + expect(result.errorMessage).toBe("Request was aborted"); + expect(events).toContain("text_delta:one"); + expect(events).not.toContain("text_delta:two"); + expect(cancelled).toBe(true); + }); + + it("sets session-id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -372,7 +547,8 @@ describe("openai-codex streaming", () => { if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; // Verify sessionId is set in headers - expect(headers?.get("session_id")).toBe(sessionId); + expect(headers?.get("session-id")).toBe(sessionId); + expect(headers?.has("session_id")).toBe(false); expect(headers?.get("x-client-request-id")).toBe(sessionId); // Verify sessionId is set in request body as prompt_cache_key @@ -721,7 +897,7 @@ describe("openai-codex streaming", () => { }, ); - it("does not set session_id/x-client-request-id headers when sessionId is not provided", async () => { + it("does not set session-id/x-client-request-id headers when sessionId is not provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -781,6 +957,7 @@ describe("openai-codex streaming", () => { if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; // Verify headers are not set when sessionId is not provided + expect(headers?.has("session-id")).toBe(false); expect(headers?.has("session_id")).toBe(false); expect(headers?.has("x-client-request-id")).toBe(false); @@ -819,6 +996,7 @@ describe("openai-codex streaming", () => { it("forwards auto transport from streamSimple options and uses cached websocket context", async () => { const token = mockToken(); const sentBodies: unknown[] = []; + let capturedWebSocketHeaders: Record | undefined; const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 })); vi.stubGlobal("fetch", fetchMock); @@ -826,7 +1004,10 @@ describe("openai-codex streaming", () => { class MockWebSocket { private listeners = new Map void>>(); - constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + constructor(_url: string, protocols?: string | string[] | { headers?: Record }) { + if (protocols && typeof protocols === "object" && !Array.isArray(protocols)) { + capturedWebSocketHeaders = protocols.headers; + } queueMicrotask(() => this.dispatch("open", {})); } @@ -917,6 +1098,9 @@ describe("openai-codex streaming", () => { }).result(); expect(sentBodies).toHaveLength(1); + expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto"); + expect(capturedWebSocketHeaders?.session_id).toBeUndefined(); + expect(capturedWebSocketHeaders?.["x-client-request-id"]).toBe("session-auto"); expect(global.fetch).not.toHaveBeenCalled(); expect(getOpenAICodexWebSocketDebugStats("session-auto")).toMatchObject({ cachedContextRequests: 1, @@ -924,6 +1108,280 @@ describe("openai-codex streaming", () => { }); }); + it("falls back to SSE when websocket connect does not open before the connect timeout", async () => { + vi.useFakeTimers(); + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + private listeners = new Map void>>(); + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(): void { + throw new Error("send should not be called before websocket open"); + } + + close(): void {} + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + sessionId: "ws-connect-timeout", + transport: "auto", + timeoutMs: 300_000, + websocketConnectTimeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getOpenAICodexWebSocketDebugStats("ws-connect-timeout")).toMatchObject({ + websocketFailures: 1, + sseFallbacks: 1, + websocketFallbackActive: true, + lastWebSocketError: "WebSocket connect timeout after 50ms", + }); + }); + + it("falls back to SSE when a websocket is idle before the first event", async () => { + vi.useFakeTimers(); + const token = mockToken(); + const sentBodies: unknown[] = []; + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + static OPEN = 1; + readyState = MockWebSocket.OPEN; + private listeners = new Map void>>(); + + constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + queueMicrotask(() => this.dispatch("open", {})); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(data: string): void { + sentBodies.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + } + + private dispatch(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + sessionId: "ws-idle-before-start", + transport: "auto", + timeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(0); + expect(sentBodies).toHaveLength(1); + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getOpenAICodexWebSocketDebugStats("ws-idle-before-start")).toMatchObject({ + websocketFailures: 1, + sseFallbacks: 1, + websocketFallbackActive: true, + }); + }); + + it("errors when a websocket is idle after the stream started", async () => { + vi.useFakeTimers(); + const token = mockToken(); + + const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + static OPEN = 1; + readyState = MockWebSocket.OPEN; + private listeners = new Map void>>(); + + constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + queueMicrotask(() => this.dispatch("open", {})); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(): void { + queueMicrotask(() => { + this.dispatch("message", { + data: JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + }), + }); + }); + } + + close(): void { + this.readyState = 3; + } + + private dispatch(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "auto", + timeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("WebSocket idle timeout after 50ms"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("sends only response input deltas in websocket-cached mode", async () => { const token = mockToken(); const sentBodies: unknown[] = []; @@ -1131,7 +1589,11 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + maxRetries: 1, + }).result(); await vi.advanceTimersByTimeAsync(0); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelay); @@ -1193,15 +1655,24 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + const retryTimeoutDelays = () => + setTimeoutSpy.mock.calls + .map((call) => call[1]) + .filter((delay): delay is number => delay === 1000 || delay === 2000 || delay === 4000); + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + maxRetries: 3, + }).result(); await vi.advanceTimersByTimeAsync(0); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 1000); + expect(retryTimeoutDelays()).toEqual([1000]); await vi.advanceTimersToNextTimerAsync(); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 2000); + expect(retryTimeoutDelays()).toEqual([1000, 2000]); await vi.advanceTimersToNextTimerAsync(); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(3, expect.any(Function), 4000); + expect(retryTimeoutDelays()).toEqual([1000, 2000, 4000]); await vi.advanceTimersToNextTimerAsync(); const result = await resultPromise; diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts index a743351c..0ecf8e13 100644 --- a/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; // Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible // backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with @@ -163,6 +163,31 @@ describe("openai-completions empty tools handling", () => { expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test"); }); + it("uses provider env before process.env for Cloudflare AI Gateway base URL", async () => { + process.env.CLOUDFLARE_ACCOUNT_ID = "process-account"; + process.env.CLOUDFLARE_GATEWAY_ID = "process-gateway"; + const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + env: { + CLOUDFLARE_ACCOUNT_ID: "provider-account", + CLOUDFLARE_GATEWAY_ID: "provider-gateway", + }, + }, + ).result(); + + const clientOptions = mockState.lastClientOptions as { baseURL?: string }; + expect(clientOptions.baseURL).toBe( + "https://gateway.ai.cloudflare.com/v1/provider-account/provider-gateway/compat", + ); + }); + it("preserves inline upstream Authorization for Cloudflare AI Gateway BYOK requests", async () => { process.env.CLOUDFLARE_ACCOUNT_ID = "account-id"; process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id"; diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts index aab20dea..ec2abea1 100644 --- a/packages/ai/test/openai-completions-response-model.test.ts +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { complete } from "../src/stream.ts"; +import { complete } from "../src/index.ts"; import type { Model } from "../src/types.ts"; // Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the @@ -60,10 +60,10 @@ describe("openai-completions responseModel", () => { it("surfaces routed chunk.model on responseModel without changing model", async () => { mockState.chunks = [ - { id: "chatcmpl-1", model: "anthropic/claude-opus-4.7", choices: [{ index: 0, delta: { content: "hi" } }] }, + { id: "chatcmpl-1", model: "anthropic/claude-opus-4.8", choices: [{ index: 0, delta: { content: "hi" } }] }, { id: "chatcmpl-1", - model: "anthropic/claude-opus-4.7", + model: "anthropic/claude-opus-4.8", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, @@ -81,7 +81,7 @@ describe("openai-completions responseModel", () => { ); expect(message.model).toBe("openrouter/auto"); - expect(message.responseModel).toBe("anthropic/claude-opus-4.7"); + expect(message.responseModel).toBe("anthropic/claude-opus-4.8"); expect(message.provider).toBe("openrouter"); expect(message.stopReason).toBe("stop"); }); diff --git a/packages/ai/test/openai-completions-retry.test.ts b/packages/ai/test/openai-completions-retry.test.ts new file mode 100644 index 00000000..cf631dd1 --- /dev/null +++ b/packages/ai/test/openai-completions-retry.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import type { Context, Model } from "../src/types.ts"; + +const mockState = vi.hoisted(() => ({ + requestOptions: [] as unknown[], +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: (_params: unknown, options: unknown) => { + mockState.requestOptions.push(options); + const stream = { + async *[Symbol.asyncIterator]() { + yield { + id: "chatcmpl-test", + choices: [{ index: 0, delta: { content: "ok" } }], + }; + yield { + id: "chatcmpl-test", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + } + return { default: FakeOpenAI }; +}); + +const model: Model<"openai-completions"> = { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, +}; + +const context: Context = { + systemPrompt: "", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }], + tools: [], +}; + +async function consume(options?: { maxRetries?: number }) { + const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options }); + for await (const _event of stream) { + void _event; + } + return stream.result(); +} + +describe("openai-completions provider retries", () => { + beforeEach(() => { + mockState.requestOptions = []; + }); + + it("disables SDK retries by default", async () => { + await consume(); + expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]); + }); + + it("honors explicit provider retry settings", async () => { + await consume({ maxRetries: 2 }); + expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 2 })]); + }); +}); diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index 1c49aad3..138eb3e3 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -34,6 +34,7 @@ const compat = { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, cacheControlFormat: undefined, diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index ec98427b..41601281 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1,9 +1,9 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { stream, streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; import { convertMessages } from "../src/providers/openai-completions.ts"; -import { streamSimple } from "../src/stream.ts"; -import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts"; +import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, @@ -64,6 +64,46 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); +const localOpenAICompletionsModel = { + api: "openai-completions", + provider: "local-vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, +} satisfies Omit, "id" | "name" | "compat">; + +type CapturedParams = { + chat_template_kwargs?: Record; + thinking?: unknown; + reasoning_effort?: string; +}; + +async function captureSimpleParams( + model: Model<"openai-completions">, + reasoning?: SimpleStreamOptions["reasoning"], +): Promise { + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + return (payload ?? mockState.lastParams) as CapturedParams; +} + describe("openai-completions tool_choice", () => { beforeEach(() => { mockState.lastParams = undefined; @@ -257,6 +297,86 @@ describe("openai-completions tool_choice", () => { expect(getModel("zai", "glm-4.5-air")?.compat?.zaiToolStream).toBeUndefined(); }); + it("stores z.ai GLM-5.2 effort metadata", () => { + for (const provider of ["zai", "zai-coding-cn"] as const) { + const model = getModel(provider, "glm-5.2")!; + expect(model.compat?.supportsReasoningEffort).toBe(true); + expect(model.thinkingLevelMap).toEqual({ + minimal: null, + low: "high", + medium: "high", + high: "high", + xhigh: "max", + }); + } + }); + + it("maps z.ai GLM-5.2 thinking levels to reasoning_effort", async () => { + const model = getModel("zai", "glm-5.2")!; + const cases = [ + { reasoning: "low", effort: "high" }, + { reasoning: "medium", effort: "high" }, + { reasoning: "high", effort: "high" }, + { reasoning: "xhigh", effort: "max" }, + ] as const; + + for (const testCase of cases) { + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + reasoning: testCase.reasoning, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBe(testCase.effort); + } + }); + + it("omits z.ai GLM-5.2 reasoning_effort when thinking is off", async () => { + const model = getModel("zai", "glm-5.2")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + it("omits tool_stream for unsupported z.ai models", async () => { const model = getModel("zai", "glm-4.5-air")!; const tools: Tool[] = [ @@ -817,6 +937,84 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); + it("uses system messages for non-OpenAI/Anthropic OpenRouter reasoning model instructions", async () => { + const model = getModel("openrouter", "deepseek/deepseek-v4-pro")!; + let payload: unknown; + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("system"); + }); + + it("keeps developer messages for OpenAI and Anthropic OpenRouter reasoning model instructions", async () => { + for (const model of [ + getModel("openrouter", "openai/gpt-5.2-codex"), + getModel("openrouter", "anthropic/claude-sonnet-4.5"), + ]) { + expect(model).toBeDefined(); + let payload: unknown; + + await streamSimple( + model!, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("developer"); + } + }); + + it("keeps developer messages for OpenAI reasoning model instructions", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-5.5")!; + const model = { ...baseModel, api: "openai-completions" } as const; + let payload: unknown; + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("developer"); + }); + + it("stores OpenRouter Kimi K2.6 reasoning replay compat in built-in metadata", () => { + const model = getModel("openrouter", "moonshotai/kimi-k2.6")!; + expect(model.compat?.supportsDeveloperRole).toBe(false); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true); + }); + it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => { const providers = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; @@ -984,6 +1182,7 @@ describe("openai-completions tool_choice", () => { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, sendSessionAffinityHeaders: false, @@ -995,6 +1194,148 @@ describe("openai-completions tool_choice", () => { expect(messages[0]).not.toHaveProperty("reasoning"); }); + it("sends thinking disabled for OpenCode Go Kimi K2.6 when thinking is off", async () => { + const model = getModel("opencode-go", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("sends thinking enabled for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { + const model = getModel("opencode-go", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("omits disabled thinking for Moonshot Kimi K2.7 Code models", async () => { + const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")]; + + for (const model of cases) { + expect(model).toBeDefined(); + let payload: unknown; + + await streamSimple( + model!, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toBeUndefined(); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("keeps disabled thinking for Moonshot Kimi K2.6 when thinking is off", async () => { + const model = getModel("moonshotai-cn", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("sends max_tokens for OpenCode completions models", async () => { + const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; + + for (const model of cases) { + let payload: unknown; + expect(model.compat?.maxTokensField).toBe("max_tokens"); + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + } + }); + + it("omits reasoning effort for OpenCode Grok Build", async () => { + const model = getModel("opencode", "grok-build-0.1")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { reasoning_effort?: string }; + expect(params.reasoning_effort).toBeUndefined(); + }); + it("does not double-count reasoning tokens in completion usage", async () => { mockState.chunks = [ { @@ -1148,4 +1489,166 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning).toEqual({ effort: "high" }); expect(params.reasoning_effort).toBeUndefined(); }); + + it("uses configurable chat template boolean thinking kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "deepseek-ai/DeepSeek-V3.1", + name: "DeepSeek V3.1 via vLLM", + compat: { + thinkingFormat: "chat-template", + supportsReasoningEffort: false, + chatTemplateKwargs: { thinking: { $var: "thinking.enabled" } }, + }, + } satisfies Model<"openai-completions">; + + for (const testCase of [ + { reasoning: "high" as const, expected: true }, + { reasoning: undefined, expected: false }, + ]) { + const params = await captureSimpleParams(model, testCase.reasoning); + + expect(params.chat_template_kwargs).toEqual({ thinking: testCase.expected }); + expect(params.thinking).toBeUndefined(); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("uses qwen chat template thinking kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "Qwen/Qwen3-Coder", + name: "Qwen3 Coder via vLLM", + compat: { + thinkingFormat: "qwen-chat-template", + supportsReasoningEffort: false, + }, + } satisfies Model<"openai-completions">; + + for (const testCase of [ + { reasoning: "high" as const, expected: true }, + { reasoning: undefined, expected: false }, + ]) { + const params = await captureSimpleParams(model, testCase.reasoning); + + expect(params.chat_template_kwargs).toEqual({ + enable_thinking: testCase.expected, + preserve_thinking: true, + }); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("uses configurable chat template effort kwargs with static kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "unsloth/gpt-oss-120b-GGUF", + name: "GPT OSS via vLLM", + thinkingLevelMap: { xhigh: "max" }, + compat: { + thinkingFormat: "chat-template", + supportsReasoningEffort: false, + chatTemplateKwargs: { + preserve_thinking: true, + reasoning_effort: { $var: "thinking.effort", omitWhenOff: true }, + }, + }, + } satisfies Model<"openai-completions">; + + const params = await captureSimpleParams(model, "xhigh"); + + expect(params.chat_template_kwargs).toEqual({ preserve_thinking: true, reasoning_effort: "max" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("uses Ant Ling compatibility metadata", async () => { + const model = getModel("ant-ling", "Ring-2.6-1T")!; + let payload: unknown; + + expect(model.compat).toMatchObject({ + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + thinkingFormat: "ant-ling", + supportsLongCacheRetention: false, + }); + expect(model.compat?.supportsStrictMode).toBeUndefined(); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined(); + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + reasoning: "high", + cacheRetention: "long", + sessionId: "ant-ling-session", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { + max_tokens?: number; + max_completion_tokens?: number; + messages?: Array<{ role?: string }>; + reasoning?: { effort?: string }; + reasoning_effort?: string; + store?: boolean; + prompt_cache_key?: string; + prompt_cache_retention?: string; + }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + expect(params.messages?.[0]?.role).toBe("system"); + expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.reasoning_effort).toBeUndefined(); + expect(params.store).toBeUndefined(); + expect(params.prompt_cache_key).toBeUndefined(); + expect(params.prompt_cache_retention).toBeUndefined(); + }); + + it("omits Ant Ling reasoning for unmapped direct reasoning efforts and non-reasoning models", async () => { + const ring = getModel("ant-ling", "Ring-2.6-1T")!; + let payload: unknown; + + await stream( + ring, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoningEffort: "medium", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning"); + + const ling = getModel("ant-ling", "Ling-2.6-flash")!; + await streamSimple( + ling, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning"); + }); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 3510f396..4458e51f 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -32,6 +32,7 @@ const compat: Required = { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, cacheControlFormat: "anthropic", diff --git a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts index d173694f..0d247b0b 100644 --- a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; describe.skipIf(!process.env.OPENAI_API_KEY)("openai responses cache affinity e2e", () => { diff --git a/packages/ai/test/openai-responses-message-id.test.ts b/packages/ai/test/openai-responses-message-id.test.ts new file mode 100644 index 00000000..f675cc8e --- /dev/null +++ b/packages/ai/test/openai-responses-message-id.test.ts @@ -0,0 +1,48 @@ +import type { ResponseOutputMessage } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { convertResponsesMessages } from "../src/providers/openai-responses-shared.ts"; +import type { AssistantMessage, Context, Usage } from "../src/types.ts"; + +const usage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +describe("OpenAI Responses message ID conversion", () => { + it("generates unique fallback message IDs for multiple text blocks in one assistant turn", () => { + const model = getModel("openai-codex", "gpt-5.5"); + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "text", text: "visible answer" }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-8", + usage, + stopReason: "stop", + timestamp: Date.now() - 1000, + }; + const context: Context = { + systemPrompt: "You are concise.", + messages: [{ role: "user", content: "hello", timestamp: Date.now() - 2000 }, assistant], + }; + + const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); + const messageIds = input + .filter( + (item): item is ResponseOutputMessage => + item.type === "message" && "id" in item && typeof item.id === "string", + ) + .map((item) => item.id); + + expect(messageIds).toEqual(["msg_pi_1", "msg_pi_1_1"]); + expect(new Set(messageIds).size).toBe(messageIds.length); + }); +}); diff --git a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts index aa6f2e23..87945bc2 100644 --- a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +++ b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, getEnvApiKey } from "../src/stream.ts"; import type { AssistantMessage, Context, Message, Tool, ToolCall } from "../src/types.ts"; const testToolSchema = Type.Object({ diff --git a/packages/ai/test/openrouter-cache-write-repro.test.ts b/packages/ai/test/openrouter-cache-write-repro.test.ts index 4bdeb286..2cf2a791 100644 --- a/packages/ai/test/openrouter-cache-write-repro.test.ts +++ b/packages/ai/test/openrouter-cache-write-repro.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { completeSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple } from "../src/stream.ts"; function createLongSystemPrompt(): string { const nonce = `${Date.now()}-${Math.random()}`; diff --git a/packages/ai/test/openrouter-images.test.ts b/packages/ai/test/openrouter-images.test.ts index 31882bec..5811e6f4 100644 --- a/packages/ai/test/openrouter-images.test.ts +++ b/packages/ai/test/openrouter-images.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { generateImages } from "../src/images.ts"; +import { clearImagesApiProviders, getImagesApiProvider } from "../src/images-api-registry.ts"; +import { register as registerOpenRouterImages } from "../src/providers/images/openrouter.ts"; +import { registerBuiltInImagesApiProviders } from "../src/providers/register-builtins.ts"; import type { ImagesContext, ImagesModel } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ @@ -62,6 +65,15 @@ describe("openrouter images", () => { beforeEach(() => { mockState.lastParams = undefined; mockState.lastRequestOptions = undefined; + clearImagesApiProviders(); + registerBuiltInImagesApiProviders(); + }); + + it("registers the direct OpenRouter images transport explicitly", () => { + clearImagesApiProviders(); + expect(getImagesApiProvider("openrouter-images")).toBeUndefined(); + registerOpenRouterImages(); + expect(getImagesApiProvider("openrouter-images")?.api).toBe("openrouter-images"); }); it("returns text plus images in final output", async () => { diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index b26948e2..62108911 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -49,6 +49,20 @@ describe("isContextOverflow", () => { expect(isContextOverflow(message, 131072)).toBe(true); }); + it("detects OpenAI-compatible parenthesized maximum context length errors", () => { + const message = createErrorMessage( + "Error: 400 Input length (265330) exceeds model's maximum context length (262144).", + ); + expect(isContextOverflow(message, 262144)).toBe(true); + }); + + it("detects OpenRouter Poolside maximum allowed input length errors", () => { + const message = createErrorMessage( + "Provider returned error: Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", + ); + expect(isContextOverflow(message, 131072)).toBe(true); + }); + it("does not treat generic non-overflow Ollama errors as overflow", () => { const message = createErrorMessage("500 `model runner crashed unexpectedly`"); expect(isContextOverflow(message, 32768)).toBe(false); diff --git a/packages/ai/test/responseid.test.ts b/packages/ai/test/responseid.test.ts index d250f5f4..bc5d6c54 100644 --- a/packages/ai/test/responseid.test.ts +++ b/packages/ai/test/responseid.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 298104dd..3a381966 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -4,8 +4,8 @@ import { dirname, join } from "path"; import { Type } from "typebox"; import { fileURLToPath } from "url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { complete, stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, stream } from "../src/stream.ts"; import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -787,6 +787,30 @@ describe("Generate E2E Tests", () => { }); }); + describe.skipIf(!process.env.NVIDIA_API_KEY)("NVIDIA NIM Provider (Nemotron 3 Super via OpenAI Completions)", () => { + const llm = getModel("nvidia", "nvidia/nemotron-3-super-120b-a12b"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { reasoningEffort: "high" }); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "high" }); + }); + }); + describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v via OpenAI Completions)", () => { const llm = getModel("openrouter", "z-ai/glm-4.5v"); @@ -1145,6 +1169,27 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.ANT_LING_API_KEY)("Ant Ling Provider (Ling 2.6 Flash via OpenAI Completions)", () => { + const llm = getModel("ant-ling", "Ling-2.6-flash"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + const ringModel = getModel("ant-ling", "Ring-2.6-1T"); + await handleThinking(ringModel, { reasoningEffort: "high" }); + }); + }); + // ========================================================================= // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // Tokens are resolved at module level (see oauthTokens above) diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 4fb6f3b2..9f5363cb 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -8,13 +8,26 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("includes xhigh for Anthropic Opus 4.7 on anthropic-messages API", () => { - const model = getModel("anthropic", "claude-opus-4-7"); + it("includes xhigh for Anthropic Opus 4.8 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-4-8"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("does not include xhigh for non-Opus Anthropic models", () => { + it("includes xhigh for Anthropic Opus 4.8 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-4-8"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + }); + + it("includes xhigh but not off for Anthropic Claude Fable 5 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); + }); + + it("does not include xhigh for Claude Sonnet 4.5", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh"); @@ -26,6 +39,18 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); + it("includes only medium/high/xhigh for OpenAI GPT-5.5 Pro", () => { + const model = getModel("openai", "gpt-5.5-pro"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); + }); + + it("includes only medium/high/xhigh for OpenRouter GPT-5.5 Pro", () => { + const model = getModel("openrouter", "openai/gpt-5.5-pro"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); + }); + it("includes only high/xhigh plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { const model = getModel("deepseek", "deepseek-v4-flash"); expect(model).toBeDefined(); @@ -38,6 +63,27 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "xhigh"]); }); + it("includes only high plus off for OpenCode Go Kimi K2.6", () => { + const model = getModel("opencode-go", "kimi-k2.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high"]); + }); + + it("excludes thinking off for Moonshot Kimi K2.7 Code models", () => { + const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")]; + + for (const model of cases) { + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["minimal", "low", "medium", "high"]); + } + }); + + it("includes only high for OpenCode Grok Build", () => { + const model = getModel("opencode", "grok-build-0.1"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["high"]); + }); + it("includes only high/xhigh plus off for DeepSeek V4 Flash on OpenRouter", () => { const model = getModel("openrouter", "deepseek/deepseek-v4-flash"); expect(model).toBeDefined(); @@ -49,4 +95,11 @@ describe("getSupportedThinkingLevels", () => { expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); + + it("includes xhigh but not off for Bedrock Claude Fable 5", () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); + }); }); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 80afc55c..fdf45b94 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; +import { stream } from "../src/index.ts"; +import { getModel, getModels } from "../src/models.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -149,9 +149,15 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider", () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const preferredCerebrasModelIds: string[] = ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"]; + const cerebrasModels = getModels("cerebras"); + const llm = cerebrasModels.find((model) => preferredCerebrasModelIds.includes(model.id)) ?? cerebrasModels[0]; it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + if (!llm) { + throw new Error("No Cerebras models available"); + } + await testTokensOnAbort(llm); }); }); @@ -289,10 +295,10 @@ describe("Token Statistics on Abort", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should include token stats when aborted mid-stream", + "claude-haiku-4.5 - should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testTokensOnAbort(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/tool-call-id-normalization.test.ts b/packages/ai/test/tool-call-id-normalization.test.ts index 0672181b..622e5127 100644 --- a/packages/ai/test/tool-call-id-normalization.test.ts +++ b/packages/ai/test/tool-call-id-normalization.test.ts @@ -12,8 +12,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple, getEnvApiKey } from "../src/stream.ts"; import type { AssistantMessage, Message, Tool, ToolResultMessage } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index f2ba0a42..b21c1ce8 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, Tool } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -297,10 +297,10 @@ describe("Tool Call Without Result Tests", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should filter out tool calls without corresponding tool results", + "claude-haiku-4.5 - should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { - const model = getModel("github-copilot", "claude-sonnet-4.6"); + const model = getModel("github-copilot", "claude-haiku-4.5"); await testToolCallWithoutResult(model, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index e828dce2..4df4b2e1 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,8 +13,8 @@ */ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, Usage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -178,18 +178,22 @@ describe("totalTokens field", () => { }); describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses", () => { - it("gpt-4o - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openai", "gpt-4o"); + it( + "claude-haiku-4.5 - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("openai", "gpt-4o"); - console.log(`\nOpenAI Responses / ${llm.id}:`); - const { first, second } = await testTotalTokensWithCache(llm); + console.log(`\nOpenAI Responses / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm); - logUsage("First request", first); - logUsage("Second request", second); + logUsage("First request", first); + logUsage("Second request", second); - assertTotalTokensEqualsComponents(first); - assertTotalTokensEqualsComponents(second); - }); + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses", () => { @@ -683,10 +687,10 @@ describe("totalTokens field", () => { ); it( - "meta-llama/llama-4-scout - should return totalTokens equal to sum of components", + "deepseek/deepseek-chat - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openrouter", "meta-llama/llama-4-scout"); + const llm = getModel("openrouter", "deepseek/deepseek-chat"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); @@ -706,10 +710,10 @@ describe("totalTokens field", () => { describe("GitHub Copilot (OAuth)", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should return totalTokens equal to sum of components", + "claude-haiku-4.5 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); console.log(`\nGitHub Copilot / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken }); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 85d8ec71..d8b02392 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -396,28 +396,28 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { describe("GitHub Copilot Provider Unicode Handling", () => { it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle emoji in tool results", + "claude-haiku-4.5 - should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmojiInToolResults(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle real-world LinkedIn comment data with emoji", + "claude-haiku-4.5 - should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "claude-sonnet-4.6 - should handle unpaired high surrogate (0xD83D) in tool results", + "claude-haiku-4.5 - should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4.6"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index 3c279863..1bda5c14 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; function makeContext(): Context { diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts new file mode 100644 index 00000000..6fb1f4b8 --- /dev/null +++ b/packages/ai/test/xiaomi-models.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getModel, getModels } from "../src/models.ts"; + +describe("Xiaomi MiMo models", () => { + it("keeps mimo-v2-flash on the API billing provider", () => { + expect(getModel("xiaomi", "mimo-v2-flash")).toBeDefined(); + }); + + it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( + "omits mimo-v2-flash from %s", + (provider) => { + expect(getModels(provider).some((model) => model.id === "mimo-v2-flash")).toBe(false); + }, + ); +}); diff --git a/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts new file mode 100644 index 00000000..9a371b60 --- /dev/null +++ b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey, streamSimple } from "../src/index.ts"; +import type { AssistantMessage, Context, Model } from "../src/types.ts"; + +const provider = "xiaomi-token-plan-ams"; +const apiKey = getEnvApiKey(provider); + +const model: Model<"anthropic-messages"> = { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro Anthropic smoke", + api: "anthropic-messages", + provider, + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 1, output: 3, cacheRead: 0.2, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 1024, + compat: { allowEmptySignature: true }, +}; + +interface AnthropicPayload { + messages?: Array<{ + role: string; + content: string | Array<{ type: string; text?: string; thinking?: string; signature?: string }>; + }>; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeInitialContext(): Context { + return { + systemPrompt: "You are concise. Follow the requested output format exactly.", + messages: [ + { + role: "user", + content: "Think internally if you need to, then reply with exactly this text and nothing else: first-ok", + timestamp: Date.now(), + }, + ], + }; +} + +function getThinkingBlocks(message: AssistantMessage) { + return message.content.filter((block) => block.type === "thinking"); +} + +async function captureReplayPayload(context: Context): Promise { + let capturedPayload: AnthropicPayload | undefined; + const stream = streamSimple(model, context, { + apiKey, + maxTokens: 512, + reasoning: "high", + onPayload: (payload) => { + capturedPayload = payload as AnthropicPayload; + throw new PayloadCaptured(); + }, + }); + + await stream.result(); + + if (!capturedPayload) { + throw new Error("Expected payload capture before request"); + } + return capturedPayload; +} + +describe.skipIf(!apiKey)("Xiaomi Token Plan AMS Anthropic empty thinking signature smoke", () => { + it("reproduces empty thinking signatures and preserves them for replay", { timeout: 60000, retry: 1 }, async () => { + const firstContext = makeInitialContext(); + const first = await completeSimple(model, firstContext, { + apiKey, + maxTokens: 512, + reasoning: "high", + }); + + expect(first.stopReason, first.errorMessage).toBe("stop"); + + const thinkingBlocks = getThinkingBlocks(first); + expect(thinkingBlocks.length).toBeGreaterThan(0); + expect(thinkingBlocks.some((block) => block.thinkingSignature === "")).toBe(true); + + const replayContext: Context = { + ...firstContext, + messages: [ + ...firstContext.messages, + first, + { + role: "user", + content: "Reply with exactly this text and nothing else: second-ok", + timestamp: Date.now(), + }, + ], + }; + + const replayPayload = await captureReplayPayload(replayContext); + const assistantPayload = replayPayload.messages?.find((message) => message.role === "assistant"); + expect(assistantPayload).toBeDefined(); + expect(Array.isArray(assistantPayload!.content)).toBe(true); + const replayedThinking = (assistantPayload!.content as Array<{ type: string; text?: string }>).filter( + (block) => block.type === "thinking", + ); + const replayedText = (assistantPayload!.content as Array<{ type: string; text?: string }>).filter( + (block) => block.type === "text", + ); + expect(replayedThinking).toEqual([{ type: "thinking", thinking: thinkingBlocks[0].thinking, signature: "" }]); + expect(replayedText.some((block) => block.text === thinkingBlocks[0].thinking)).toBe(false); + }); +}); diff --git a/packages/ai/test/zen.test.ts b/packages/ai/test/zen.test.ts index 8ca80014..82a2079f 100644 --- a/packages/ai/test/zen.test.ts +++ b/packages/ai/test/zen.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { MODELS } from "../src/models.generated.ts"; -import { complete } from "../src/stream.ts"; import type { Model } from "../src/types.ts"; describe.skipIf(!process.env.OPENCODE_API_KEY)("OpenCode Models Smoke Test", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce69a3aa..ba9b1ae7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,20 +2,427 @@ ## [Unreleased] +## [0.79.9] - 2026-06-20 + +### New Features + +- **Chat-template thinking compatibility** - OpenAI-compatible custom providers can map Pi thinking levels into `chat_template_kwargs`, enabling vLLM/Hugging Face chat-template models such as DeepSeek to use provider-native thinking controls. See [Custom Provider API Types](docs/custom-provider.md#api-types) and [OpenAI Compatibility](docs/models.md#openai-compatibility). +- **GLM-5.2 provider improvements** - GLM-5.2 now has corrected Fireworks OpenAI-compatible routing and OpenRouter `xhigh` thinking support, improving `/model` behavior and high-effort reasoning for GLM-5.2 users. See [Model Options](docs/usage.md#model-options). + ### Added -- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). +- Added inherited configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)). + +### Fixed + +- Fixed inherited Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)). +- Fixed same-directory session switches to reuse imported extension modules while preserving fresh extension instances and lifecycle events ([#5905](https://github.com/earendil-works/pi/issues/5905)). +- Fixed deep session branches taking quadratic time to build context or branch paths ([#5909](https://github.com/earendil-works/pi/issues/5909)). +- Fixed inherited OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)). +- Fixed inherited Markdown streaming code fence rendering so partial closing fences no longer make code blocks shrink or flicker while content streams ([#5846](https://github.com/earendil-works/pi/pull/5846) by [@xl0](https://github.com/xl0)). +- Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). +- Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). +- Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). +- Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)). + +## [0.79.8] - 2026-06-19 + +### New Features + +- **Selective provider base entry points** - SDK users can pair `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with explicit provider registration to keep bundled applications from including unused provider transports. See [`pi-ai` Base Entry Point](../ai/README.md#base-entry-point) and [`pi-agent-core` Base Entry Point](../agent/README.md#base-entry-point). +- **Mistral prompt caching** - Mistral sessions now use provider-side prompt caching with session affinity and cached-token usage/cost accounting. See [API Keys](docs/providers.md#api-keys) and [Environment Variables](docs/usage.md#environment-variables). +- **Post-compaction token estimates** - Compact results and compaction events now include estimated post-compaction token counts so clients can show the approximate context reduction. See [RPC compact](docs/rpc.md#compact) and [compaction events](docs/rpc.md#compaction_start--compaction_end). +- **OpenRouter Fusion alias** - `openrouter/fusion` is available as a built-in OpenRouter model alias. See [API Keys](docs/providers.md#api-keys). + +### Added + +- Added inherited `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` entry points for selective provider registration in bundled applications ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). +- Added inherited Mistral prompt caching using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). +- Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)). +- Added the inherited OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). + +### Fixed + +- Updated vulnerable runtime dependencies, including `undici` and the packaged `protobufjs` transitive dependency. +- Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)). +- Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)). + +## [0.79.7] - 2026-06-18 + +### New Features + +- **Automatic theme mode** - `/settings` can choose separate light and dark themes and follow terminal color-scheme changes. See [Selecting a Theme](docs/themes.md#selecting-a-theme). +- **Self-only updates by default** - `pi update` now updates pi only, with `pi update --all` for updating pi and packages together. See [Install and Manage](docs/packages.md#install-and-manage). +- **Extension API helpers** - extensions can use `CONFIG_DIR_NAME` for project config paths and import edit diff helpers for edit-style diffs. See [`ctx.cwd`](docs/extensions.md#ctxcwd) and [SDK Exports](docs/sdk.md#exports). +- **Warp inline images** - Warp terminals now get inline image rendering through Kitty graphics detection. See [Image](docs/tui.md#image). + +### Added + +- Added automatic theme mode so `/settings` can use separate light and dark themes and follow terminal color-scheme changes ([#5874](https://github.com/earendil-works/pi/pull/5874)). +- Added inherited Warp terminal image capability detection so inline images render through Warp's Kitty graphics support ([#5841](https://github.com/earendil-works/pi/pull/5841) by [@dodiego](https://github.com/dodiego)). +- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi` ([#5869](https://github.com/earendil-works/pi/pull/5869) by [@xl0](https://github.com/xl0)). +- Exported edit diff helpers (`generateDiffString`, `generateUnifiedPatch`, and `EditDiffResult`) from the public API for extensions that need edit-style diffs ([#5756](https://github.com/earendil-works/pi/pull/5756) by [@xl0](https://github.com/xl0)). ### Changed +- Changed bare `pi update` to update only pi, added `pi update --all` for updating pi and extensions together, and clarified extension update prompts. +- Reserved `/` in theme names for automatic light/dark theme settings. +- Updated extension docs, examples, runtime help, trust prompts, and config labels to use the configured project config directory instead of hardcoded `.pi` paths. + +### Fixed + +- Fixed RPC unknown-command errors to include the request id so clients do not hang waiting for a response ([#5868](https://github.com/earendil-works/pi/issues/5868)). +- Fixed `/model` autocomplete and model selection searches to match provider/model queries regardless of whether the provider or model token is typed first. +- Fixed the tree navigator to horizontally pan deep entries so the selected item remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)). + +## [0.79.6] - 2026-06-16 + +### Fixed + +- Fixed HTTP dispatcher configuration to preserve a caller's deliberate `fetch` override instead of reinstalling the undici global fetch over it. +- Fixed inherited OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter. + +## [0.79.5] - 2026-06-16 + +### New Features + +- **Provider-scoped API key environments** - `auth.json` API key entries can now include `env` overrides for provider-specific Cloudflare, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy settings without changing the project shell. See [Auth File](docs/providers.md#auth-file). +- **Global HTTP proxy setting** - Configure `httpProxy` once in global settings to apply `HTTP_PROXY` and `HTTPS_PROXY` to Pi-managed HTTP clients. See [Network](docs/settings.md#network). +- **Vercel AI Gateway attribution** - Vercel AI Gateway requests now include Pi attribution headers by default. See [API Keys](docs/providers.md#api-keys). + +### Added + +- Added Vercel AI Gateway request attribution headers (`http-referer` and `x-title`) for Vercel AI Gateway models ([#5798](https://github.com/earendil-works/pi/pull/5798) by [@rwachtler](https://github.com/rwachtler)). +- Added an `xp` footer marker when experimental features are enabled. +- Added a global `httpProxy` setting that applies as `HTTP_PROXY` and `HTTPS_PROXY` for Pi-managed HTTP clients ([#5790](https://github.com/earendil-works/pi/issues/5790)). +- Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)). + +### Changed + +- Updated the vendored Markdown parser used by HTML session exports to `marked` 18.0.5. + +### Fixed + +- Fixed inherited OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)). +- Fixed inherited OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)). +- Fixed device-code login to stop opening the browser automatically. +- Fixed inherited editor Cursor Up handling so non-empty drafts jump to the start of the line before browsing input history ([#5789](https://github.com/earendil-works/pi/pull/5789) by [@4h9fbZ](https://github.com/4h9fbZ)). +- Fixed inherited Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). +- Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)). +- Fixed inherited Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). +- Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)). +- Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). + +## [0.79.4] - 2026-06-15 + +### New Features + +- **Automatic first-run theme selection** - pi detects the terminal background on first run and defaults to the `dark` or `light` theme. See [Selecting a Theme](docs/themes.md#selecting-a-theme). +- **Standalone binary integrity checksums** - GitHub release assets now include `SHA256SUMS` files for verifying standalone binary downloads. See [Quickstart Install](docs/quickstart.md#install). + +### Added + +- Added `SHA256SUMS` integrity files to standalone binary GitHub release assets ([#5739](https://github.com/earendil-works/pi/issues/5739)). +- Added first-run interactive theme detection from the terminal background ([#5385](https://github.com/earendil-works/pi/pull/5385) by [@vegarsti](https://github.com/vegarsti)). + +### Fixed + +- Fixed bash tool output collection to keep draining stdout/stderr after the child exits while descendants still write, avoiding truncated late output ([#5753](https://github.com/earendil-works/pi/pull/5753) by [@Mearman](https://github.com/Mearman)). +- Fixed `/tree` help rendering to show compact wrapped controls instead of truncating them on narrow terminals ([#5055](https://github.com/earendil-works/pi/issues/5055)). +- Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)). +- Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)). +- Fixed question and questionnaire extension examples to wrap long prompt, option, and help text instead of truncating it ([#5708](https://github.com/earendil-works/pi/pull/5708) by [@xl0](https://github.com/xl0)). +- Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). +- Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). +- Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). +- Fixed inherited Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)). +- Fixed inherited GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)). +- Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). +- Fixed inherited overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)). +- Fixed inherited WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). +- Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). + +## [0.79.3] - 2026-06-13 + +### Fixed + +- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to use the observed 272k-token Codex backend limit, avoiding a billing hazard from prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)). + +## [0.79.2] - 2026-06-12 + +### New Features + +- **Clearer Bedrock validation guidance** - Amazon Bedrock data retention validation errors now link to AWS data retention documentation. See [Amazon Bedrock](docs/providers.md#amazon-bedrock). + +### Added + +- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json` ([#5587](https://github.com/earendil-works/pi/pull/5587) by [@vegarsti](https://github.com/vegarsti)). +- Added AWS data retention documentation links to inherited Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). + +### Fixed + +- Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)). +- Fixed experimental first-time setup to skip forked sessions instead of rerunning the setup prompts ([#5627](https://github.com/earendil-works/pi/pull/5627) by [@vegarsti](https://github.com/vegarsti)). +- Fixed inherited OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)). +- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). +- Fixed inherited Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)). +- Increased the inherited OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed inherited Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). +- Fixed inherited late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)). +- Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). +- Fixed inherited slash-separated fuzzy queries so provider/model completions remain matchable after insertion. +- Fixed inherited WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). +- Fixed inherited editor wrapping for CJK text to break at character boundaries instead of leaving large trailing gaps ([#5585](https://github.com/earendil-works/pi/pull/5585) by [@haoqixu](https://github.com/haoqixu)). +- Fixed inherited loose Markdown list rendering to preserve blank-line separation between list items ([#5562](https://github.com/earendil-works/pi/pull/5562) by [@Perlence](https://github.com/Perlence)). +- Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). +- Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). +- Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). +- Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5560](https://github.com/earendil-works/pi/pull/5560) by [@haoqixu](https://github.com/haoqixu)). + +## [0.79.1] - 2026-06-09 + +### New Features + +- **Claude Fable 5** - Claude Fable 5 is now available on the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. +- **Prompt template defaults** - Prompt templates can use default positional arguments such as `${1:-7}` for optional values. See [Prompt Template Arguments](docs/prompt-templates.md#arguments). +- **Configurable project trust defaults** - `defaultProjectTrust` lets users choose whether unresolved project trust asks, always trusts, or never trusts by default, and extensions can inspect effective trust decisions. See [Project Trust](docs/security.md#project-trust) and [`ctx.isProjectTrusted()`](docs/extensions.md#ctxisprojecttrusted). +- **Natural extension autocomplete triggers** - Extension autocomplete providers can declare trigger characters such as `#` or `$` so suggestions open without slash-command prefixes. See [Autocomplete Providers](docs/extensions.md#autocomplete-providers). + +### Added + +- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5553](https://github.com/earendil-works/pi/pull/5553) by [@dannote](https://github.com/dannote)). +- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt in to early features ([#5547](https://github.com/earendil-works/pi/pull/5547) by [@vegarsti](https://github.com/vegarsti)). +- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). +- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. +- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). +- Added Claude Fable 5 model support inherited from `@earendil-works/pi-ai` for the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. + +### Fixed + +- Fixed inherited Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). +- Fixed inherited IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). +- Fixed inherited z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed inherited OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). +- Fixed inherited Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed inherited Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed inherited Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed inherited OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed inherited prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). +- Fixed inherited wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). +- Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)). +- Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). +- Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). +- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). +- Fixed CLI help and version output, including plain redirected `--help`/`--version` output and simplified `list`/`config` help text. +- Fixed `/new` from ephemeral sessions to keep the new session ephemeral instead of persisting it by default ([#5045](https://github.com/earendil-works/pi/issues/5045)). +- Clarified custom model docs that `name` and `modelOverrides.name` do not replace model IDs in the footer or primary model lists ([#4841](https://github.com/earendil-works/pi/issues/4841)). + +## [0.79.0] - 2026-06-08 + +### New Features + +- **Project trust for local inputs** - Pi now asks before loading project-local settings, resources, instructions, and packages, with saved decisions and `--approve` / `--no-approve` controls for non-interactive modes. See [Project Trust](README.md#project-trust). +- **Extension-controlled trust decisions** - Global and CLI extensions can handle `project_trust`, decide, remember, or defer project trust before project-local resources load. See [`project_trust`](docs/extensions.md#project_trust). +- **Cache-hit visibility in the footer** - The interactive footer now shows the latest prompt cache hit rate (`CH`). See [Interactive Mode](README.md#interactive-mode). +- **Richer SDK and RPC extension surfaces** - Public exports now include RPC extension UI request/response types and package asset path helpers. See [Extension UI Protocol](docs/rpc.md#extension-ui-protocol) and [SDK Exports](docs/sdk.md#exports). + +### Added + +- Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches. +- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). +- Added the latest prompt cache hit rate to the interactive footer. +- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). +- Exported coding-agent package asset path helpers from the public API ([#5415](https://github.com/earendil-works/pi/issues/5415)). + +### Fixed + +- Fixed package exports by removing the stale `./hooks` subpath that pointed at non-existent build output. +- Fixed inherited TUI rendering to clear stale lines when content shrinks to zero. +- Fixed inherited autocomplete suggestions to refresh after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). +- Fixed `/reload` to persist project trust when an implicitly trusted session creates a project `.pi` directory. +- Fixed project trust input discovery to traverse parent directories portably. +- Fixed inherited intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). +- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). +- Fixed `models.json` schema support and inherited OpenAI Responses custom-provider handling for `compat.supportsDeveloperRole: false` ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed inherited prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward ([#5454](https://github.com/earendil-works/pi/issues/5454)). +- Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). +- Fixed inherited OpenRouter routing preferences on OpenAI-compatible custom providers to work when the custom provider base URL does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). +- Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). +- Fixed skill-wrapped prompts to insert spacing between skill instructions and the user message ([#5371](https://github.com/earendil-works/pi/pull/5371) by [@Perlence](https://github.com/Perlence)). + +## [0.78.1] - 2026-06-04 + +### New Features + +- **More built-in provider coverage** - Added Ant Ling and NVIDIA NIM provider setup, plus MiniMax-M3 support for the direct MiniMax providers. See [Providers](docs/providers.md). +- **Richer extension context** - Extensions can use `ctx.mode` and `ctx.getSystemPromptOptions()` to adapt behavior across TUI, RPC, JSON, and print modes and inspect base system prompt inputs. See [Extensions](docs/extensions.md). + +### Added + +- Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM. +- Added Ant Ling provider selection and setup documentation. +- Added MiniMax-M3 model support inherited from `@earendil-works/pi-ai` for the `minimax` and `minimax-cn` direct providers ([#5313](https://github.com/earendil-works/pi/issues/5313)). +- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers. +- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. +- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs ([#5306](https://github.com/earendil-works/pi/pull/5306) by [@xl0](https://github.com/xl0)). + +### Fixed + +- Fixed temporary extension package installs to use a private `~/.pi/agent/tmp/extensions` directory with `0700` permissions instead of `os.tmpdir()/pi-extensions`. +- Fixed git package source handling to reject unsafe host/path components and keep managed clone paths inside install roots. +- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. +- Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). +- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). +- Fixed inherited Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). +- Fixed inherited Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed inherited OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). +- Fixed inherited OpenRouter Kimi K2.6 thinking replay and developer-role instruction handling ([#5309](https://github.com/earendil-works/pi/issues/5309)). +- Fixed inherited OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)). +- Fixed inherited overlay focus restoration so non-capturing overlays remain interactive after UI rerenders and explicit focus release ([#5235](https://github.com/earendil-works/pi/pull/5235) by [@nicobailon](https://github.com/nicobailon)). +- Fixed inherited tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)). +- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). +- Fixed the footer branch display in WSL `/mnt/...` repositories to refresh after branch changes ([#5264](https://github.com/earendil-works/pi/pull/5264) by [@psoukie](https://github.com/psoukie)). +- Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)). +- Restored inherited NVIDIA Qwen 3.5 122B NIM model support. + +## [0.78.0] - 2026-05-29 + +### New Features + +- **Named startup sessions** - `--name` / `-n` sets the session display name before startup across interactive, print, JSON, and RPC modes. See [Naming Sessions](docs/sessions.md#naming-sessions) and [Session Options](docs/usage.md#session-options). +- **Clickable file tool paths** - built-in file tool titles render OSC 8 `file://` hyperlinks when the terminal supports them, including supported tmux clients. + +### Added + +- Exported `convertToPng` for extension authors ([#5167](https://github.com/earendil-works/pi-mono/pull/5167) by [@xl0](https://github.com/xl0)). +- Exported `parseArgs` and type `Args` for extension authors ([#5202](https://github.com/earendil-works/pi-mono/pull/5202) by [@xl0](https://github.com/xl0)). +- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi-mono/issues/5153)). +- Added a resume command hint when exiting interactive sessions ([#5176](https://github.com/earendil-works/pi-mono/pull/5176) by [@yzhg1983](https://github.com/yzhg1983)). +- Added OSC 8 `file://` hyperlinks to file paths shown in built-in file tool titles ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). +- Added custom Amazon Bedrock request header support inherited from `@earendil-works/pi-ai` ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)). + +### Fixed + +- Clarified the WezTerm/WSL IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi-mono/issues/5200)). +- Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi-mono/issues/5201)). +- Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi-mono/issues/5184)). +- Fixed early interactive input typed before the prompt loop starts so it is buffered instead of dropped ([#5195](https://github.com/earendil-works/pi-mono/pull/5195) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)). +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)). +- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events. +- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters. +- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). +- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi-mono/issues/5185)). + +## [0.77.0] - 2026-05-28 + +### New Features + +- **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage. +- **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options). +- **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex). +- **Streaming-aware extension input** - extensions can distinguish idle prompts, mid-stream steers, and queued follow-ups with `InputEvent.streamingBehavior`. See [Input Events](docs/extensions.md#input-events). + +### Added + +- Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). +- Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). +- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it. + +### Fixed + +- Fixed startup timing output so `readPipedStdin` no longer includes `createAgentSessionRuntime` work ([#4829](https://github.com/earendil-works/pi/issues/4829)). +- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)). +- Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. +- Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). +- Fixed keyboard protocol negotiation to ignore mismatched or delayed terminal responses, avoiding false Kitty keyboard protocol detection ([#5091](https://github.com/earendil-works/pi/pull/5091) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). +- Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). +- Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). +- Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). +- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). +- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). +- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. +- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). +- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)). +- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). +- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). +- Fixed system prompt tool-selection guidance to avoid preferring unavailable file exploration tools ([#5132](https://github.com/earendil-works/pi/issues/5132)). +- Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). + +## [0.76.0] - 2026-05-27 + +### New Features + +- **Explicit session IDs for automation** - `--session-id ` lets scripts create or resume an exact project-local session. See [Sessions](docs/usage.md#sessions). +- **RPC bash output can stay out of model context** - RPC clients can pass `excludeFromContext` to `bash` for commands whose output should not be sent with the next prompt. See [RPC mode](docs/rpc.md#bash). +- **More predictable provider retries and timeouts** - Codex WebSocket/SSE waits are bounded, and `retry.provider.maxRetries` controls provider retries instead of hidden SDK defaults. See [Retry settings](docs/settings.md#retry). +- **Better terminal editing across environments** - Apple Terminal Shift+Enter, Windows/JetBrains capability detection, and Unicode-aware word navigation improve interactive editing. See [Terminal setup](docs/terminal-setup.md) and [Keybindings](docs/keybindings.md). + +### Added + +- Added `--session-id` to let CLI callers use an exact project-local session ID, creating it if missing ([#4874](https://github.com/earendil-works/pi/issues/4874)). +- Added `excludeFromContext` flag to the `bash` RPC command for parity with the internal `executeBash` API ([#5039](https://github.com/earendil-works/pi/issues/5039)). + +### Fixed + +- Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). +- Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)). +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). +- Fixed `httpIdleTimeoutMs` to apply to OpenAI Codex Responses WebSocket idle waits, added `websocketConnectTimeoutMs` for bounded WebSocket connect waits, and added a 10s Codex SSE response-header timeout ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). +- Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). +- Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). +- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). +- Fixed `openai-codex/gpt-5.3-codex-spark` model metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). +- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). +- Fixed provider retry controls so `retry.provider.maxRetries` is honored, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed Apple Terminal `Shift+Enter` by detecting local macOS modifier state when Terminal.app sends plain Return. +- Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). +- Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)). +- Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)). +- Fixed the development docs `AGENTS.md` link to point at the pi-mono guidelines ([#5041](https://github.com/earendil-works/pi/issues/5041)). + +## [0.75.5] - 2026-05-23 + +### New Features + +- **Cleaner read tool output** - Collapsed `read` tool cards now show only the read line by default, while `Ctrl+O` still expands the full file content. +- **Faster file tools on Windows** - Built-in file tools now use async filesystem operations during streaming, and image resizes run off the main TUI thread in a worker. +- **More reliable package updates** - `pi update` and git package installs now reconcile pinned git refs and keep package settings intact. See [Packages](docs/packages.md). +- **Custom Anthropic-compatible adaptive thinking** - Custom provider model configs can opt into adaptive-thinking Claude behavior with `compat.forceAdaptiveThinking`. See [Custom providers](docs/custom-provider.md) and [Models](docs/models.md). + +### Added + +- Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). +- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). +- Added a Codex subscription login method selector with device-code auth for headless environments. + +### Changed + +- Changed collapsed read tool cards to show only the read line until expanded ([#4916](https://github.com/earendil-works/pi/issues/4916)). +- Replaced the inherited optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)). - Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)). ### Fixed +- Fixed `pi update` to reconcile git-pinned packages to their configured ref ([#4869](https://github.com/earendil-works/pi/issues/4869)). +- Fixed package/resource path handling for Windows and glob/pattern resolution ([#4873](https://github.com/earendil-works/pi-mono/pull/4873) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed config pattern matching to resolve patterns from the correct base directory ([#4898](https://github.com/earendil-works/pi-mono/pull/4898) by [@haoqixu](https://github.com/haoqixu)). +- Fixed theme pickers to list themes by their content name instead of file stem ([#4830](https://github.com/earendil-works/pi-mono/pull/4830) by [@Perlence](https://github.com/Perlence)). +- Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)). +- Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)). +- Fixed inherited Amazon Bedrock Claude requests to send the model output token cap by default, avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). +- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). +- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). - Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). +- Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)). +- Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)). +- Fixed coding-agent tools to avoid synchronous filesystem operations during streaming and moved image resizing off the main TUI thread ([#4756](https://github.com/earendil-works/pi-mono/pull/4756) by [@mitsuhiko](https://github.com/mitsuhiko)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 00fca81e..68e17a62 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -7,11 +7,6 @@ Discord npm

-

- pi.dev domain graciously donated by -

- Exy mascot
exe.dev
-

> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](../../CONTRIBUTING.md). @@ -110,9 +105,11 @@ For each built-in provider, pi maintains a list of tool-capable models, updated **API keys:** - Anthropic +- Ant Ling - OpenAI - Azure OpenAI - DeepSeek +- NVIDIA NIM - Google Gemini - Google Vertex - Amazon Bedrock @@ -125,6 +122,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - OpenRouter - Vercel AI Gateway - ZAI +- ZAI Coding Plan (China) - OpenCode Zen - OpenCode Go - Hugging Face @@ -152,7 +150,7 @@ The interface from top to bottom: - **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions - **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI - **Editor** - Where you type; border color indicates thinking level -- **Footer** - Working directory, session name, total token/cache usage, cost, context usage, current model +- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays. @@ -183,6 +181,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/name ` | Set session display name | | `/session` | Show session info (file, ID, messages, tokens, cost) | | `/tree` | Jump to any point in the session and continue from there | +| `/trust` | Save project trust decision for future sessions (restart required) | | `/fork` | Create a new session from a previous user message | | `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Manually compact context, optional custom instructions | @@ -239,6 +238,7 @@ Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory. pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode (don't save) +pi --name "my task" # Set session display name at startup pi --session # Use specific session file or ID pi --fork # Fork specific session file or ID into a new session ``` @@ -284,12 +284,26 @@ Use `/settings` to modify common options, or edit JSON files directly: See [docs/settings.md](docs/settings.md) for all options. +### Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. + +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + ### Telemetry and update checks Pi has two separate startup features: - **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check. -- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled. +- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. @@ -302,7 +316,7 @@ Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from: - Parent directories (walking up from cwd) - Current directory -Use for project instructions, conventions, common commands. All matching files are concatenated. +Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. Disable context file loading with `--no-context-files` (or `-nc`). @@ -400,7 +414,8 @@ pi install ssh://git@github.com/user/repo@v1 # tag or commit pi remove npm:@foo/pi-tools pi uninstall npm:@foo/pi-tools # alias for remove pi list -pi update # update pi and packages (skips pinned packages) +pi update # update pi only +pi update --all # update pi and packages pi update --extensions # update packages only pi update --self # update pi only pi update --self --force # reinstall pi even if current @@ -408,7 +423,7 @@ pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. +Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update --extensions` and `pi update --all`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding a `pi` key to `package.json`: @@ -499,7 +514,8 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source|self|pi] # Update pi and packages (skips pinned packages) +pi update [source|self|pi] # Update pi only, or one package source +pi update --all # Update pi and packages pi update --extensions # Update packages only pi update --self # Update pi only pi update --self --force # Reinstall pi even if current @@ -508,6 +524,8 @@ pi list # List installed packages pi config # Enable/disable package resources ``` +`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. + ### Modes | Flag | Description | @@ -545,12 +563,14 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork specific session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode (don't save) | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific tool names across built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific tool names across built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | @@ -579,6 +599,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | @@ -604,6 +626,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" @@ -619,6 +644,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question + # High thinking level pi --thinking high "Solve this complex problem" ``` @@ -632,7 +660,7 @@ pi --thinking high "Solve this complex problem" | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | -| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | +| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | @@ -642,8 +670,6 @@ pi --thinking high "Solve this complex problem" See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines and [docs/development.md](docs/development.md) for setup, forking, and debugging. ---- - ## License MIT @@ -653,3 +679,9 @@ MIT - [@earendil-works/pi-ai](https://www.npmjs.com/package/@earendil-works/pi-ai): Core LLM toolkit - [@earendil-works/pi-agent-core](https://www.npmjs.com/package/@earendil-works/pi-agent-core): Agent framework - [@earendil-works/pi-tui](https://www.npmjs.com/package/@earendil-works/pi-tui): Terminal UI components + +

+ pi.dev domain graciously donated by +

+ Exy mascot
exe.dev
+

diff --git a/packages/coding-agent/docs/containerization.md b/packages/coding-agent/docs/containerization.md new file mode 100644 index 00000000..33f2df3d --- /dev/null +++ b/packages/coding-agent/docs/containerization.md @@ -0,0 +1,111 @@ +# Containerization + +Pi runs with all permissions by default, but in some cases, you will want to have more control over what directories Pi can write to and which accesses it has. + +There are two general options. You can either +1. run the whole `pi` process inside an isolated environment, or +2. run `pi` on the host and route tool execution into an isolated environment. + +## Choose a pattern + +| Pattern | What is isolated | Best for | Notes | +| --- | --- | --- | --- | +| Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). | +| Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. | +| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway | + +Extensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations. + +## Gondolin + +[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM. +Use the [example extension](../examples/extensions/gondolin) when you want `pi` on the host but all built-in tools routed into the VM. + +Setup: + +```bash +cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin +cd ~/.pi/agent/extensions/gondolin +npm install --ignore-scripts +``` + +Run from the project you want mounted: + +```bash +cd /path/to/project +pi -e ~/.pi/agent/extensions/gondolin +``` + +The extension mounts the host cwd at `/workspace` in the VM and overrides `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. +User `!` commands are routed into the VM, as well. +File changes under `/workspace` write through to the host. + +Requirements: Node.js >= 23.6.0 for `@earendil-works/gondolin`, plus QEMU (requires installation through your package manager). + +## Plain Docker + +Run the whole `pi` process in Docker when you want the simplest local container boundary. + +`Dockerfile.pi`: + +```dockerfile +FROM node:24-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent + +WORKDIR /workspace +ENTRYPOINT ["pi"] +``` + +Build and run: + +```bash +docker build -t pi-sandbox -f Dockerfile.pi . + +docker run --rm -it \ + -e ANTHROPIC_API_KEY \ + -v "$PWD:/workspace" \ + -v pi-agent-home:/root/.pi/agent \ + pi-sandbox +``` + +The `-v "$PWD:/workspace"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example. + +Use a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container. + +## OpenShell + +Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls. +OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway. + +Every sandbox requires an active gateway. +Register and select one before creating a sandbox: + +```bash +openshell gateway add --name +openshell gateway select +``` + +Launch `pi` inside an OpenShell sandbox: + +```bash +openshell sandbox create --name pi-sandbox --from pi -- pi +``` + +In this pattern, the whole `pi` process runs inside the sandbox. +Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary. + +If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine. +Clone the repository inside the sandbox or use OpenShell file transfer commands: + +```bash +openshell sandbox upload pi-sandbox ./repo /workspace +openshell sandbox download pi-sandbox /workspace/repo ./repo-out +``` + +OpenShell providers can keep raw model API keys outside the sandbox. +When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream. +Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route. diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 09eb6e6e..612d1e60 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -43,7 +43,7 @@ export default function (pi: ExtensionAPI) { pi.registerProvider("my-provider", { name: "My Provider", baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", api: "openai-completions", models: [ { @@ -83,7 +83,7 @@ pi.registerProvider("openai", { pi.registerProvider("google", { baseUrl: "https://ai-gateway.corp.com/google", headers: { - "X-Corp-Auth": "CORP_AUTH_TOKEN" // env var or literal + "X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal } }); ``` @@ -112,7 +112,7 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", - apiKey: "LOCAL_OPENAI_API_KEY", + apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, @@ -132,7 +132,7 @@ This registers the fetched models before startup finishes. ```typescript pi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", - apiKey: "MY_LLM_API_KEY", // env var name or literal value + apiKey: "$MY_LLM_API_KEY", // env var reference api: "openai-completions", // which streaming API to use models: [ { @@ -155,6 +155,8 @@ pi.registerProvider("my-llm", { When `models` is provided, it **replaces** all existing models for that provider. +`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`. + ## Unregister Provider Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`: @@ -163,7 +165,7 @@ Use `pi.unregisterProvider(name)` to remove a provider that was previously regis // Register pi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", - apiKey: "MY_LLM_API_KEY", + apiKey: "$MY_LLM_API_KEY", api: "openai-completions", models: [ { @@ -227,9 +229,11 @@ models: [{ }] ``` -Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. +For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay. + > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. > Use `mistral-conversations` for native Mistral models. > If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed. @@ -241,7 +245,7 @@ If your provider expects `Authorization: Bearer ` but doesn't use a standar ```typescript pi.registerProvider("custom-api", { baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", authHeader: true, // adds Authorization: Bearer header api: "openai-completions", models: [...] @@ -263,17 +267,28 @@ pi.registerProvider("corporate-ai", { name: "Corporate AI (SSO)", async login(callbacks: OAuthLoginCallbacks): Promise { - // Option 1: Browser-based OAuth - callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); - - // Option 2: Device code flow - callbacks.onDeviceCode({ - userCode: "ABCD-1234", - verificationUri: "https://sso.corp.com/device" + const method = await callbacks.onSelect({ + message: "Select login method:", + options: [ + { id: "browser", label: "Browser OAuth" }, + { id: "device", label: "Device code" } + ] }); + if (!method) throw new Error("Login cancelled"); - // Option 3: Prompt for token/code - const code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + let code: string; + if (method === "device") { + callbacks.onDeviceCode({ + userCode: "ABCD-1234", + verificationUri: "https://sso.corp.com/device", + intervalSeconds: 5, + expiresInSeconds: 900 + }); + code = await pollDeviceCodeUntilComplete(); + } else { + callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); + code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + } // Exchange for tokens (your implementation) const tokens = await exchangeCodeForTokens(code); @@ -322,10 +337,21 @@ interface OAuthLoginCallbacks { onAuth(params: { url: string }): void; // Show device code (for device authorization flow) - onDeviceCode(params: { userCode: string; verificationUri: string }): void; + onDeviceCode(params: { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }): void; // Prompt user for input (for manual token entry) onPrompt(params: { message: string }): Promise; + + // Show an interactive selector, e.g. to choose browser OAuth vs device code + onSelect(params: { + message: string; + options: { id: string; label: string }[]; + }): Promise; } ``` @@ -568,7 +594,7 @@ Register your stream function: ```typescript pi.registerProvider("my-provider", { baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", api: "my-custom-api", models: [...], streamSimple: streamMyProvider @@ -605,7 +631,7 @@ interface ProviderConfig { /** API endpoint URL. Required when defining models. */ baseUrl?: string; - /** API key or environment variable name. Required when defining models (unless oauth). */ + /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */ apiKey?: string; /** API type for streaming. Required at provider or model level when defining models. */ @@ -618,7 +644,7 @@ interface ProviderConfig { options?: SimpleStreamOptions ) => AssistantMessageEventStream; - /** Custom headers to include in requests. Values can be env var names. */ + /** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */ headers?: Record; /** If true, adds Authorization: Bearer header with the resolved API key. */ @@ -680,8 +706,9 @@ interface ProviderModelConfig { /** Custom headers for this specific model. */ headers?: Record; - /** OpenAI compatibility settings for openai-completions API. */ + /** Compatibility settings for the selected API. */ compat?: { + // openai-completions supportsStore?: boolean; supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; @@ -691,11 +718,20 @@ interface ProviderModelConfig { requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; - thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; + chatTemplateKwargs?: Record; cacheControlFormat?: "anthropic"; + + // anthropic-messages + supportsEagerToolInputStreaming?: boolean; + supportsLongCacheRetention?: boolean; + sendSessionAffinityHeaders?: boolean; + supportsCacheControlOnTools?: boolean; + forceAdaptiveThinking?: boolean; + allowEmptySignature?: boolean; }; } ``` -`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index 35202a4a..fc57befa 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -1,6 +1,6 @@ # Development -See [AGENTS.md](../../../AGENTS.md) for additional guidelines. +See [AGENTS.md](https://github.com/earendil-works/pi-mono/blob/main/AGENTS.md) for additional guidelines. ## Setup diff --git a/packages/coding-agent/docs/docs.json b/packages/coding-agent/docs/docs.json index f96321d9..bbc9e74b 100644 --- a/packages/coding-agent/docs/docs.json +++ b/packages/coding-agent/docs/docs.json @@ -19,6 +19,14 @@ "title": "Providers", "path": "providers.md" }, + { + "title": "Security", + "path": "security.md" + }, + { + "title": "Containerization", + "path": "containerization.md" + }, { "title": "Settings", "path": "settings.md" diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ce922f2a..5a559813 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -109,7 +109,7 @@ pi -e ./my-extension.ts > **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. -Extensions are auto-discovered from: +Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted. | Location | Scope | |----------|-------| @@ -199,7 +199,7 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", - apiKey: "LOCAL_OPENAI_API_KEY", + apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, @@ -216,6 +216,12 @@ export default async function (pi: ExtensionAPI) { This pattern makes the fetched models available during normal startup and to `pi --list-models`. +### Long-lived resources and shutdown + +Extension factories may run in invocations that never start a session. Do not start background resources such as processes, sockets, file watchers, or timers from the factory. + +Defer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start. + ### Extension Styles **Single file** - simplest, for small extensions: @@ -270,6 +276,7 @@ Run `npm install` in the extension directory, then imports from `node_modules/` ``` pi starts │ + ├─► project_trust (user/global and CLI extensions only, before project resources load) ├─► session_start { reason: "startup" } └─► resources_discover { reason: "startup" } │ @@ -334,6 +341,25 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) └─► session_shutdown ``` +### Startup Events + +#### project_trust + +Fired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. + +```typescript +pi.on("project_trust", async (event, ctx) => { + // event.cwd - current working directory + // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers + if (await ctx.ui.confirm("Trust project?", event.cwd)) { + return { trusted: "yes", remember: true }; + } + return { trusted: "undecided" }; +}); +``` + +A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default. + ### Resource Events #### resources_discover @@ -451,7 +477,7 @@ pi.on("session_tree", async (event, ctx) => { #### session_shutdown -Fired before an extension runtime is torn down. +Fired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks. ```typescript pi.on("session_shutdown", async (event, ctx) => { @@ -819,6 +845,9 @@ pi.on("input", async (event, ctx) => { // event.text - raw input (before skill/template expansion) // event.images - attached images, if any // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) + // event.streamingBehavior - "steer" | "followUp" | undefined + // undefined when idle, "steer" for mid-stream interrupts, + // "followUp" for messages queued until the agent finishes // Transform: rewrite input before expansion if (event.text.startsWith("?quick ")) @@ -847,7 +876,7 @@ pi.on("input", async (event, ctx) => { - `transform` - modify text/images, then continue to expansion - `handled` - skip agent entirely (first handler to return this wins) -Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts). +Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing. ## ExtensionContext @@ -857,14 +886,38 @@ All handlers receive `ctx: ExtensionContext`. UI methods for user interaction. See [Custom UI](#custom-ui) for full details. +### ctx.mode + +Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "tui"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering. + ### ctx.hasUI -`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). +`true` in TUI and RPC modes. `false` in print mode (`-p`) and JSON mode. Use this to guard dialog methods (`select`, `confirm`, `input`, `editor`) and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) that work in both TUI and RPC modes. In RPC mode, some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). ### ctx.cwd Current working directory. +Use `CONFIG_DIR_NAME` instead of hardcoding `.pi` when constructing project-local config paths. Rebranded distributions can use a different config directory name. + +```typescript +import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { join } from "node:path"; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json"); + // ... + }); +} +``` + +### ctx.isProjectTrusted() + +Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. + +Use this before reading project-local extension configuration that should only be honored for trusted projects. + ### ctx.sessionManager Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. @@ -975,6 +1028,19 @@ pi.on("before_agent_start", (event, ctx) => { Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. +### ctx.getSystemPromptOptions() + +Returns the base inputs Pi currently uses to build the system prompt. + +```typescript +const options = ctx.getSystemPromptOptions(); +const contextPaths = options.contextFiles?.map((file) => file.path) ?? []; +``` + +This has the same shape and mutability as `before_agent_start` `event.systemPromptOptions`: custom prompt, active tools, tool snippets, prompt guidelines, appended system prompt text, cwd, loaded context files, and loaded skills. It may include full context file contents, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata. + +This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites. + ### ctx.waitForIdle() Wait for the agent to finish streaming: @@ -1482,24 +1548,25 @@ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) -Manage active tools. This works for both built-in tools and dynamically registered tools. +Manage active tools. This works for both built-in tools and dynamically registered tools. `pi.getActiveTools()` returns the active tool names as `string[]`; `pi.getAllTools()` returns metadata for all configured tools. ```typescript -const active = pi.getActiveTools(); +const active = pi.getActiveTools(); // ["read", "bash", ...] const all = pi.getAllTools(); -// [{ +// all = [{ // name: "read", // description: "Read file contents...", -// parameters: ..., +// parameters: ..., +// promptGuidelines: ["Use read to examine files instead of cat or sed."], // sourceInfo: { path: "", source: "builtin", scope: "temporary", origin: "top-level" } // }, ...] -const names = all.map(t => t.name); const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin"); const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk"); +pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // Keep current tools and enable my_custom_tool pi.setActiveTools(["read", "bash"]); // Switch to read-only ``` -`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`. +`pi.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`. Typical `sourceInfo.source` values: - `builtin` for built-in tools @@ -1551,7 +1618,7 @@ If you need to discover models from a remote endpoint, prefer an async extension pi.registerProvider("my-proxy", { name: "My Proxy", baseUrl: "https://proxy.example.com", - apiKey: "PROXY_API_KEY", // env var name or literal + apiKey: "$PROXY_API_KEY", // env var reference api: "anthropic-messages", models: [ { @@ -1598,7 +1665,7 @@ pi.registerProvider("corporate-ai", { **Config options:** - `name` - Display name for the provider in UI such as `/login`. - `baseUrl` - API endpoint URL. Required when defining models. -- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). +- `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution. - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. - `headers` - Custom headers to include in requests. - `authHeader` - If true, adds `Authorization: Bearer` header automatically. @@ -2234,6 +2301,7 @@ ctx.ui.pasteToEditor("pasted content"); // Stack custom autocomplete behavior on top of the built-in provider ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, line, col, options) { const beforeCursor = (lines[line] ?? "").slice(0, col); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); @@ -2282,7 +2350,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t ### Autocomplete Providers -Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`. Typical pattern: @@ -2294,6 +2362,7 @@ Typical pattern: ```typescript pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); @@ -2367,7 +2436,7 @@ const result = await ctx.ui.custom( ); ``` -For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically: ```typescript const result = await ctx.ui.custom( @@ -2375,12 +2444,19 @@ const result = await ctx.ui.custom( { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, - onHandle: (handle) => { /* handle.setHidden(true/false) */ } + onHandle: (handle) => { + handle.focus(); // focus this overlay and bring it to the visual front + // handle.unfocus({ target: editorComponent }); // release input to a specific component + // handle.setHidden(true/false); // toggle visibility + // handle.hide(); // permanently remove + } } ); ``` -See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. +A focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component. + +See [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. ### Custom Editor @@ -2505,14 +2581,14 @@ const highlighted = highlightCode(code, lang, theme); ## Mode Behavior -| Mode | UI Methods | Notes | -|------|-----------|-------| -| Interactive | Full TUI | Normal operation | -| RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) | -| JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) | -| Print (`-p`) | No-op | Extensions run but can't prompt | +| Mode | `ctx.mode` | `ctx.hasUI` | Notes | +|------|------------|-------------|-------| +| Interactive | `"tui"` | `true` | Full TUI with terminal rendering | +| RPC (`--mode rpc`) | `"rpc"` | `true` | Dialogs and notifications via JSON protocol; `custom()` returns `undefined`. See [rpc.md](rpc.md) | +| JSON (`--mode json`) | `"json"` | `false` | Event stream to stdout; UI methods are no-ops | +| Print (`-p`) | `"print"` | `false` | Extensions run but can't prompt | -In non-interactive modes, check `ctx.hasUI` before using UI methods. +Use `ctx.mode === "tui"` before TUI-specific features (`custom()`, component factories, terminal input). Use `ctx.hasUI` before dialog and notification methods that work in both TUI and RPC modes. ## Examples Reference @@ -2539,10 +2615,12 @@ All examples in [examples/extensions/](../examples/extensions/). | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | **Events & Gates** ||| | `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | +| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | | `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | | `input-transform.ts` | Transform user input | `on("input")` | +| `input-transform-streaming.ts` | Streaming-aware input transform | `on("input")`, `streamingBehavior` | | `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` | | `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` | | `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` | @@ -2553,6 +2631,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | | `trigger-compact.ts` | Trigger compaction manually | `compact()` | | `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` | +| `git-merge-and-resolve.ts` | Fetch, merge, and resolve conflicts | `on("agent_end")`, `exec`, `sendUserMessage` | | `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | | **UI Components** ||| | `status-line.ts` | Footer status indicator | `setStatus`, session events | @@ -2576,6 +2655,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations | | `interactive-shell.ts` | Persistent shell session | `on("user_bash")` | | `sandbox/` | Sandboxed tool execution | Tool operations | +| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | Tool operations, built-in tool overrides, `on("user_bash")` | | `subagent/` | Spawn sub-agents | `registerTool`, `exec` | | **Games** ||| | `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling | diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 75a527d6..b5ee017e 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -41,6 +41,8 @@ For the full first-run flow, see [Quickstart](quickstart.md). - [Quickstart](quickstart.md) - install, authenticate, and run a first session. - [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference. - [Providers](providers.md) - subscription and API-key setup for built-in providers. +- [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting. +- [Containerization](containerization.md) - sandbox pi with Gondolin, Docker, or OpenShell. - [Settings](settings.md) - global and project settings. - [Keybindings](keybindings.md) - default shortcuts and custom keybindings. - [Sessions](sessions.md) - session management, branching, and tree navigation. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 12636700..5679981a 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -101,7 +101,7 @@ Use `google-generative-ai` with a `baseUrl` to add models from Google AI Studio, "my-google": { "baseUrl": "https://generativelanguage.googleapis.com/v1beta", "api": "google-generative-ai", - "apiKey": "GEMINI_API_KEY", + "apiKey": "$GEMINI_API_KEY", "models": [ { "id": "gemma-4-31b-it", @@ -143,18 +143,25 @@ Set `api` at provider level (default for all models) or model level (override pe ### Value Resolution -The `apiKey` and `headers` fields support three formats: +The `apiKey` and `headers` fields support command execution, environment interpolation, and literals: -- **Shell command:** `"!command"` executes and uses stdout +- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout ```json "apiKey": "!security find-generic-password -ws 'anthropic'" "apiKey": "!op read 'op://vault/item/credential'" ``` -- **Environment variable:** Uses the value of the named variable +- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals. ```json - "apiKey": "MY_API_KEY" + "apiKey": "$MY_API_KEY" + "apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}" ``` -- **Literal value:** Used directly + `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved. +- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution. + ```json + "apiKey": "$$literal-dollar-prefix" + "apiKey": "$!literal-bang-prefix" + ``` +- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables. ```json "apiKey": "sk-..." ``` @@ -172,10 +179,10 @@ If your command is slow, expensive, rate-limited, or should keep using a previou "providers": { "custom-proxy": { "baseUrl": "https://proxy.example.com/v1", - "apiKey": "MY_API_KEY", + "apiKey": "$MY_API_KEY", "api": "anthropic-messages", "headers": { - "x-portkey-api-key": "PORTKEY_API_KEY", + "x-portkey-api-key": "$PORTKEY_API_KEY", "x-secret": "!op read 'op://vault/item/secret'" }, "models": [...] @@ -189,7 +196,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | Field | Required | Default | Description | |-------|----------|---------|-------------| | `id` | Yes | — | Model identifier (passed to the API) | -| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown in model details/status text. | +| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown as secondary model detail text. | | `api` | No | provider's `api` | Override provider's API for this model | | `reasoning` | No | `false` | Supports extended thinking | | `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) | @@ -200,8 +207,8 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. | Current behavior: -- `/model` and `--list-models` list entries by model `id`. -- The configured `name` is used for model matching and detail/status text. +- `/model`, `--list-models`, and the interactive footer display entries by model `id`. +- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id. ### Thinking Level Map @@ -268,7 +275,7 @@ To merge custom models into a built-in provider, include the `models` array: "providers": { "anthropic": { "baseUrl": "https://my-proxy.example.com/v1", - "apiKey": "ANTHROPIC_API_KEY", + "apiKey": "$ANTHROPIC_API_KEY", "api": "anthropic-messages", "models": [...] } @@ -311,24 +318,31 @@ Behavior notes: - `modelOverrides` are applied to built-in provider models. - Unknown model IDs are ignored. - You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. +- Overriding `name` changes model matching and secondary detail text only; the footer and primary model lists continue to show the model `id`. - If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry. ## Anthropic Messages Compatibility -For providers or proxies using `api: "anthropic-messages"`, use `compat.supportsEagerToolInputStreaming` to control Anthropic fine-grained tool streaming compatibility. +For providers or proxies using `api: "anthropic-messages"`, use `compat` to control Anthropic-specific request compatibility. By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead. +Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`. + +Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures. + ```json { "providers": { "anthropic-proxy": { "baseUrl": "https://proxy.example.com", "api": "anthropic-messages", - "apiKey": "ANTHROPIC_PROXY_KEY", + "apiKey": "$ANTHROPIC_PROXY_KEY", "compat": { "supportsEagerToolInputStreaming": false, - "supportsLongCacheRetention": true + "supportsLongCacheRetention": true, + "forceAdaptiveThinking": true, + "allowEmptySignature": true }, "models": [ { @@ -346,6 +360,10 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro |-------|-------------| | `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. | | `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. | +| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. | +| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. | +| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. | +| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. | ## OpenAI Compatibility @@ -381,14 +399,15 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | -| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | +| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | +| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | | `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | -`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`. +`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates. `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. @@ -399,7 +418,7 @@ Example: "providers": { "openrouter": { "baseUrl": "https://openrouter.ai/api/v1", - "apiKey": "OPENROUTER_API_KEY", + "apiKey": "$OPENROUTER_API_KEY", "api": "openai-completions", "models": [ { @@ -449,7 +468,7 @@ Vercel AI Gateway example: "providers": { "vercel-ai-gateway": { "baseUrl": "https://ai-gateway.vercel.sh/v1", - "apiKey": "AI_GATEWAY_API_KEY", + "apiKey": "$AI_GATEWAY_API_KEY", "api": "openai-completions", "models": [ { diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 1f3c69a6..73f2dccb 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -28,17 +28,18 @@ pi install ./relative/path/to/package pi remove npm:@foo/bar pi list # show installed packages from settings -pi update # update pi and all non-pinned packages -pi update --extensions # update all non-pinned packages only +pi update # update pi only +pi update --all # update pi, update packages, and reconcile pinned git refs +pi update --extensions # update packages and reconcile pinned git refs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). -By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup. +By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: @@ -58,7 +59,7 @@ npm:@scope/pkg@1.2.3 npm:pkg ``` -- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`). +- Versioned specs are pinned and skipped by package updates (`pi update --extensions`, `pi update --all`). - User installs go under `~/.pi/agent/npm/`. - Project installs go under `.pi/npm/`. - Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. @@ -85,9 +86,10 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. -- Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref. +- Refs are pinned tags or commits. `pi update --extensions` and `pi update --all` do not move them to newer refs, but they do reconcile an existing clone to the configured ref. +- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). -- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists. +- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists. **SSH examples:** ```bash diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 056d2c9a..d8990bea 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam Pi loads prompt templates from: - Global: `~/.pi/agent/prompts/*.md` -- Project: `.pi/prompts/*.md` +- Project: `.pi/prompts/*.md` (only after the project is trusted) - Packages: `prompts/` directories or `pi.prompts` entries in `package.json` - Settings: `prompts` array with files or directories - CLI: `--prompt-template ` (repeatable) @@ -64,10 +64,11 @@ Type `/` followed by the template name in the editor. Autocomplete shows availab ## Arguments -Templates support positional arguments and simple slicing: +Templates support positional arguments, defaults, and simple slicing: - `$1`, `$2`, ... positional args - `$@` or `$ARGUMENTS` for all args joined +- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default` - `${@:N}` for args from the Nth position (1-indexed) - `${@:N:L}` for `L` args starting at N @@ -80,6 +81,12 @@ description: Create a component Create a React component named $1 with features: $@ ``` +Default values are useful for optional arguments: + +```markdown +Summarize the current state in ${1:-7} bullet points. +``` + Usage: `/component Button "onClick handler" "disabled support"` ## Loading Rules diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 7e80f931..86f1de06 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -49,9 +49,11 @@ pi | Provider | Environment Variable | `auth.json` key | |----------|----------------------|------------------| | Anthropic | `ANTHROPIC_API_KEY` | `anthropic` | +| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | OpenAI | `OPENAI_API_KEY` | `openai` | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | +| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` | | Google Gemini | `GEMINI_API_KEY` | `google` | | Mistral | `MISTRAL_API_KEY` | `mistral` | | Groq | `GROQ_API_KEY` | `groq` | @@ -62,6 +64,7 @@ pi | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` | | ZAI | `ZAI_API_KEY` | `zai` | +| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` | | OpenCode Zen | `OPENCODE_API_KEY` | `opencode` | | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | | Hugging Face | `HF_TOKEN` | `huggingface` | @@ -84,8 +87,10 @@ Store credentials in `~/.pi/agent/auth.json`: ```json { "anthropic": { "type": "api_key", "key": "sk-ant-..." }, + "ant-ling": { "type": "api_key", "key": "..." }, "openai": { "type": "api_key", "key": "sk-..." }, "deepseek": { "type": "api_key", "key": "sk-..." }, + "nvidia": { "type": "api_key", "key": "nvapi-..." }, "google": { "type": "api_key", "key": "..." }, "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, @@ -99,22 +104,48 @@ Store credentials in `~/.pi/agent/auth.json`: The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. +API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. + +```json +{ + "cloudflare-ai-gateway": { + "type": "api_key", + "key": "$CLOUDFLARE_API_KEY", + "env": { + "CLOUDFLARE_API_KEY": "...", + "CLOUDFLARE_ACCOUNT_ID": "account-id", + "CLOUDFLARE_GATEWAY_ID": "gateway-id" + } + } +} +``` + +Use this when pi should use different provider settings than the project shell environment. + ### Key Resolution -The `key` field supports three formats: +The `key` field supports command execution, environment interpolation, and literals: -- **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime) +- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout (cached for process lifetime) ```json { "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" } { "type": "api_key", "key": "!op read 'op://vault/item/credential'" } ``` -- **Environment variable:** Uses the value of the named variable +- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals. ```json - { "type": "api_key", "key": "MY_ANTHROPIC_KEY" } + { "type": "api_key", "key": "$MY_ANTHROPIC_KEY" } + { "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" } ``` -- **Literal value:** Used directly + `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved. +- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution. + ```json + { "type": "api_key", "key": "$$literal-dollar-prefix" } + { "type": "api_key", "key": "$!literal-bang-prefix" } + ``` +- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables. ```json { "type": "api_key", "key": "sk-ant-..." } + { "type": "api_key", "key": "public" } ``` OAuth credentials are also stored here after `/login` and managed automatically. @@ -181,7 +212,7 @@ export AWS_BEDROCK_FORCE_HTTP1=1 ### Cloudflare AI Gateway -`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug must be set as environment variables. +`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug can be set as environment variables or in the API key credential's `env` object in `auth.json`. ```bash export CLOUDFLARE_API_KEY=... # or use /login @@ -205,7 +236,7 @@ For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires ### Cloudflare Workers AI -`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` must be set as an environment variable. +`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` can be set as an environment variable or in the API key credential's `env` object in `auth.json`. ```bash export CLOUDFLARE_API_KEY=... # or use /login diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index c7266346..59026738 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -136,6 +136,7 @@ Sessions are saved automatically: ```bash pi -c # Continue most recent session pi -r # Browse previous sessions +pi --name "my task" # Set session display name at startup pi --session # Open a specific session ``` diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 95d73e04..4eefe4d9 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -13,6 +13,7 @@ pi --mode rpc [options] Common options: - `--provider `: Set the LLM provider (anthropic, openai, google, etc.) - `--model `: Model pattern or ID (supports `provider/id` and optional `:`) +- `--name ` / `-n `: Set the session display name at startup - `--no-session`: Disable session persistence - `--session-dir `: Custom session storage directory @@ -373,11 +374,14 @@ Response: "summary": "Summary of conversation...", "firstKeptEntryId": "abc123", "tokensBefore": 150000, + "estimatedTokensAfter": 32000, "details": {} } } ``` +`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count. + #### set_auto_compaction Enable or disable automatic compaction when context is nearly full. @@ -702,7 +706,7 @@ Response: } ``` -The current session name is available via `get_state` in the `sessionName` field. +The current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name ` or `-n ` to the `pi --mode rpc` process. ### Commands @@ -931,6 +935,7 @@ The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`. "summary": "Summary of conversation...", "firstKeptEntryId": "abc123", "tokensBefore": 150000, + "estimatedTokensAfter": 32000, "details": {} }, "aborted": false, @@ -1010,7 +1015,7 @@ Some `ExtensionUIContext` methods are not supported or degraded in RPC mode beca - `getTheme()` returns `undefined` - `setTheme()` returns `{ success: false, error: "..." }` -Note: `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. +Note: `ctx.mode` is `"rpc"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === "tui"` to guard TUI-specific features like `custom()` that require a real terminal. ### Extension UI Requests (stdout) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 8377185e..0c521e74 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -472,6 +472,7 @@ Specify which built-in tools to enable: - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled +- `excludeTools` disables specific built-in, extension, or custom tool names after any `tools` allowlist is applied The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers. @@ -487,6 +488,11 @@ const { session } = await createAgentSession({ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); + +// Disable one tool while keeping the rest available +const { session } = await createAgentSession({ + excludeTools: ["ask_question"], +}); ``` #### Tools with Custom cwd @@ -1104,8 +1110,14 @@ DefaultResourceLoader type ResourceLoader createEventBus -// Helpers +// Constants and helpers +CONFIG_DIR_NAME defineTool +getAgentDir +getPackageDir +getReadmePath +getDocsPath +getExamplesPath // Session management SessionManager diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md new file mode 100644 index 00000000..29828e03 --- /dev/null +++ b/packages/coding-agent/docs/security.md @@ -0,0 +1,59 @@ +# Security + +Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary. + +## Project Trust + +Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. + +Pi considers a project to have resources that require trust when it finds any of these from the current working directory: + +- `.pi/settings.json` +- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes` +- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md` +- project `.agents/skills` in the current directory or an ancestor directory + +A bare `.pi` directory does not count as a project resource that requires trust. + +When an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. + +Trusting a project allows pi to load project resources that require trust, including: + +- `.pi/settings.json` +- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files +- missing project packages configured through project settings +- project-local extensions and project package-managed extensions + +Declining trust skips protected resources. `AGENTS.md` and `CLAUDE.md` context files are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, pi only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +## No Built-in Sandbox + +Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions. Package installs, shell commands, language servers, test commands, and other developer tools behave as ordinary local processes. + +This is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary. + +Project trust is only an input-loading guard. It prevents a repository from silently changing pi's settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi. + +## Running Untrusted or Unmonitored Work + +For untrusted repositories, generated code you do not intend to monitor closely, or unattended automation, run pi in a contained environment. Use a container, VM, micro-VM, remote sandbox, or policy-controlled sandbox with only the files and credentials required for the task. + +Common patterns are documented in [Containerization](containerization.md): + +- run the whole `pi` process inside a container/sandbox +- run host pi while routing built-in tool execution into a Gondolin micro-VM +- mount only the workspace paths the agent should access +- avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials +- pass the minimum required API keys or use short-lived credentials +- restrict network access when the task does not need it +- review diffs and outputs before copying results back to trusted systems + +If you bind-mount a host workspace read/write, writes from inside the container or VM can still modify host files. Use read-only mounts or copy files into and out of the sandbox when you need stronger protection from unintended writes. + +## Reporting Security Issues + +To report a security issue, follow the repository [Security Policy](https://github.com/earendil-works/pi-mono/blob/main/SECURITY.md). Do not open a public issue for security-sensitive reports. + +Expected local-agent behavior, lack of a built-in sandbox, prompt injection from untrusted content, and behavior of user-installed extensions or skills are generally outside the security boundary unless the report demonstrates a real privilege-boundary bypass or shows how pi grants access that the local user did not already have. diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 848fc61a..c32ce390 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -282,7 +282,7 @@ Set `label` to `undefined` to clear a label. ### SessionInfoEntry -Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions. +Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions. ```json {"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"} diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index d262f853..1a50ee02 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -10,6 +10,7 @@ Sessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. E pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or partial session ID pi --fork # Fork a session file or partial session ID into a new session ``` @@ -56,6 +57,13 @@ Use `/name ` to set a human-readable session name: /name Refactor auth module ``` +Set the name at startup with `--name` or `-n`: + +```bash +pi --name "Refactor auth module" +pi --name "CI audit" -p "Review this build failure" +``` + Named sessions are easier to find in `/resume` and `pi -r`. ## Branching with `/tree` diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index befd4da3..18c9ad97 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -9,6 +9,18 @@ Pi uses JSON settings files with project settings overriding global settings. Edit directly or use `/settings` for common options. +## Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. + +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + ## All Settings ### Model & Thinking @@ -40,13 +52,16 @@ Edit directly or use `/settings` for common options. |---------|------|---------|-------------| | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) | | `quietStartup` | boolean | `false` | Hide startup header | +| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | | `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | +| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) | +| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | -| `showHardwareCursor` | boolean | `false` | Show terminal cursor | +| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | ### Telemetry and update checks @@ -54,6 +69,18 @@ Edit directly or use `/settings` for common options. Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. +### Network + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `httpProxy` | string | - | HTTP proxy URL applied as `HTTP_PROXY` and `HTTPS_PROXY`. Global setting only. | + +```json +{ + "httpProxy": "http://127.0.0.1:7890" +} +``` + ### Warnings | Setting | Type | Default | Description | @@ -101,11 +128,13 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off | `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts | | `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) | | `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds | -| `retry.provider.maxRetries` | number | SDK default | Provider/SDK retry attempts | +| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts | | `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) | When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap. +Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances. + ```json { "retry": { @@ -127,7 +156,9 @@ When a provider requests a retry delay longer than `retry.provider.maxRetryDelay |---------|------|---------|-------------| | `steeringMode` | string | `"one-at-a-time"` | How steering messages are sent: `"all"` or `"one-at-a-time"` | | `followUpMode` | string | `"one-at-a-time"` | How follow-up messages are sent: `"all"` or `"one-at-a-time"` | -| `transport` | string | `"sse"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, or `"auto"` | +| `transport` | string | `"auto"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, `"websocket-cached"`, or `"auto"` | +| `httpIdleTimeoutMs` | number | `300000` | HTTP header/body idle timeout in milliseconds, also used by providers with explicit stream idle timeouts. Set to `0` to disable. | +| `websocketConnectTimeoutMs` | number | `15000` | WebSocket connect/open handshake timeout in milliseconds for providers that support WebSocket transports. Set to `0` to disable. | ### Terminal & Images diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index cb96dd97..d3ffeea8 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -26,7 +26,7 @@ Pi loads skills from: - Global: - `~/.pi/agent/skills/` - `~/.agents/skills/` -- Project: +- Project (only after the project is trusted): - `.pi/skills/` - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - Packages: `skills/` directories or `pi.skills` entries in `package.json` diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 74b0df2a..ff1638b5 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -6,6 +6,12 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-p Work out of the box. +## Apple Terminal + +Pi enables enhanced key reporting when available. If Terminal.app still sends plain Return for `Shift+Enter`, pi uses a local macOS modifier fallback to treat that Return as `Shift+Enter`. + +This fallback only works when pi runs on the same Mac as Terminal.app. It cannot detect the local keyboard over remote SSH. + ## Ghostty Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux): @@ -34,7 +40,7 @@ If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` t ## WezTerm -Create `~/.wezterm.lua`: +WezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`: ```lua local wezterm = require 'wezterm' @@ -43,14 +49,50 @@ config.enable_kitty_keyboard = true return config ``` +On macOS, WezTerm binds `Option+Enter` to fullscreen by default. To use `Option+Enter` for pi follow-up queueing, add this key override: + +```lua +local wezterm = require 'wezterm' +local config = wezterm.config_builder() +config.keys = { + { + key = 'Enter', + mods = 'ALT', + action = wezterm.action.SendString('\x1b[13;3u'), + }, +} +return config +``` + +If you already have a `config.keys` table, add the entry to it. + +On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings. + +## Alacritty + +Alacritty usually works out of the box for `Shift+Enter`. On macOS, `Option+Enter` may arrive as plain `Enter`. To use `Option+Enter` for pi follow-up queueing, add to `~/.config/alacritty/alacritty.toml`: + +```toml +[[keyboard.bindings]] +key = "Enter" +mods = "Alt" +chars = "\u001b[13;3u" +``` + +Restart Alacritty after changing the config. + ## VS Code (Integrated Terminal) +VS Code 1.109.5 and newer enable Kitty keyboard protocol in the integrated terminal by default, so `Shift+Enter` should work out of the box. + +VS Code versions older than 1.109.5 need an explicit terminal keybinding for `Shift+Enter`. + `keybindings.json` locations: - macOS: `~/Library/Application Support/Code/User/keybindings.json` - Linux: `~/.config/Code/User/keybindings.json` - Windows: `%APPDATA%\\Code\\User\\keybindings.json` -Add to `keybindings.json` to enable `Shift+Enter` for multi-line input: +Add to `keybindings.json`: ```json { diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 8762943d..11655128 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -20,7 +20,7 @@ Pi loads themes from: - Built-in: `dark`, `light` - Global: `~/.pi/agent/themes/*.json` -- Project: `.pi/themes/*.json` +- Project: `.pi/themes/*.json` (only after the project is trusted) - Packages: `themes/` directories or `pi.themes` entries in `package.json` - Settings: `themes` array with files or directories - CLI: `--theme ` (repeatable) @@ -137,7 +137,7 @@ vim ~/.pi/agent/themes/my-theme.json } ``` -- `name` is required and must be unique. +- `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. - `colors` must define all 51 required tokens. diff --git a/packages/coding-agent/docs/tmux.md b/packages/coding-agent/docs/tmux.md index efe6ab57..0083b5f1 100644 --- a/packages/coding-agent/docs/tmux.md +++ b/packages/coding-agent/docs/tmux.md @@ -18,7 +18,7 @@ tmux kill-server tmux ``` -Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. +Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. The `extended-keys-format` option requires tmux 3.5 or later. ## Why `csi-u` Is Recommended @@ -57,5 +57,7 @@ This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newli ## Requirements -- tmux 3.2 or later (run `tmux -V` to check) +- tmux 3.5 or later for `extended-keys-format csi-u` (run `tmux -V` to check) - A terminal emulator that supports extended keys (Ghostty, Kitty, iTerm2, WezTerm, Windows Terminal) + +With tmux 3.2 through 3.4, omit `extended-keys-format csi-u`; Pi still supports tmux's default xterm `modifyOtherKeys` format. diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index bb78aab4..c8ff3554 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -50,9 +50,9 @@ When a `Focusable` component has focus, TUI: 1. Sets `focused = true` on the component 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence) 3. Positions the hardware terminal cursor at that location -4. Shows the hardware cursor +4. Shows the hardware cursor only when `showHardwareCursor` is enabled -This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface. +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. ### Container Components with Embedded Inputs @@ -145,8 +145,11 @@ const result = await ctx.ui.custom( // Responsive: hide on narrow terminals visible: (termWidth, termHeight) => termWidth >= 80, }, - // Get handle for programmatic visibility control + // Get handle for programmatic focus and visibility control onHandle: (handle) => { + // handle.focus() - focus this overlay and bring it to the visual front + // handle.unfocus() - release input to normal fallback + // handle.unfocus({ target }) - release input to a specific component or null // handle.setHidden(true/false) - toggle visibility // handle.hide() - permanently remove }, @@ -154,6 +157,12 @@ const result = await ctx.ui.custom( ); ``` +### Overlay Focus + +A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input. + +Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again. + ### Overlay Lifecycle Overlay components are disposed when closed. Don't reuse references - create fresh instances: @@ -248,7 +257,7 @@ md.setText("Updated markdown"); ### Image -Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm). +Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp). ```typescript const image = new Image( diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 65081090..5ff0cf73 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -76,6 +76,7 @@ Sessions are saved automatically to `~/.pi/agent/sessions/`, organized by workin pi -c # Continue most recent session pi -r # Browse and select a session pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or session ID pi --fork # Fork a session into a new session file ``` @@ -109,6 +110,21 @@ Replace the default system prompt with: Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location. +### Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. + +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + + ## Exporting and Sharing Sessions Use `/export [file]` to write a session to HTML. @@ -129,15 +145,16 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source|self|pi] # Update pi and packages; skips pinned packages -pi update --extensions # Update packages only +pi update [source|self|pi] # Update pi only, or one package source +pi update --all # Update pi and packages; reconcile pinned git refs +pi update --extensions # Update packages only; reconcile pinned git refs pi update --self # Update pi only pi update --extension # Update one package pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. See [Pi Packages](packages.md) for package sources and security notes. @@ -178,12 +195,14 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork a session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode; do not save | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | @@ -216,6 +235,8 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | @@ -241,6 +262,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" @@ -255,6 +279,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" + +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question ``` ### Environment Variables @@ -266,7 +293,7 @@ pi --tools read,grep,find,ls -p "Review the code" | `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | -| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | +| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index b7521df2..d4f4f2cc 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -19,10 +19,12 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | Extension | Description | |-----------|-------------| | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `project-trust.ts` | Demonstrates the `project_trust` event for user/global and CLI extensions | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) | | `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | | `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config | +| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | ### Custom Tools @@ -75,6 +77,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow | | `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook | | `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation | +| `input-transform-streaming.ts` | Skips expensive input preprocessing for mid-stream steering via `streamingBehavior` | ### Git Integration diff --git a/packages/coding-agent/examples/extensions/custom-header.ts b/packages/coding-agent/examples/extensions/custom-header.ts index e3aeb656..600a85ce 100644 --- a/packages/coding-agent/examples/extensions/custom-header.ts +++ b/packages/coding-agent/examples/extensions/custom-header.ts @@ -47,7 +47,7 @@ function getPiMascot(theme: Theme): string[] { export default function (pi: ExtensionAPI) { // Set custom header immediately on load (if UI is available) pi.on("session_start", async (_event, ctx) => { - if (ctx.hasUI) { + if (ctx.mode === "tui") { ctx.ui.setHeader((_tui, theme) => { return { render(_width: number): string[] { diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts index 69267f40..51426362 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts @@ -568,7 +568,7 @@ function streamCustomAnthropic( export default function (pi: ExtensionAPI) { pi.registerProvider("custom-anthropic", { baseUrl: "https://api.anthropic.com", - apiKey: "CUSTOM_ANTHROPIC_API_KEY", + apiKey: "$CUSTOM_ANTHROPIC_API_KEY", api: "custom-anthropic-api", models: [ diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index c78ae920..9a131279 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.75.4", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.75.4", + "version": "0.79.9", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 89aa420a..8b4182e7 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.75.4", + "version": "0.79.9", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts index 23d0a40a..49e9c353 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts @@ -20,6 +20,7 @@ import { type SimpleStreamOptions, streamSimpleAnthropic, streamSimpleOpenAIResponses, + type ThinkingLevelMap, } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -49,6 +50,7 @@ interface GitLabModel { backend: Backend; baseUrl: string; reasoning: boolean; + thinkingLevelMap?: ThinkingLevelMap; input: ("text" | "image")[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; @@ -57,12 +59,37 @@ interface GitLabModel { export const MODELS: GitLabModel[] = [ // Anthropic + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + backend: "anthropic", + baseUrl: ANTHROPIC_PROXY_URL, + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + backend: "anthropic", + baseUrl: ANTHROPIC_PROXY_URL, + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 1000000, + maxTokens: 64000, + }, { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 200000, @@ -74,6 +101,7 @@ export const MODELS: GitLabModel[] = [ backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, contextWindow: 200000, @@ -85,12 +113,24 @@ export const MODELS: GitLabModel[] = [ backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, contextWindow: 200000, maxTokens: 8192, }, // OpenAI (all use Responses API) + { + id: "gpt-5.5-2026-04-23", + name: "GPT-5.5", + backend: "openai", + baseUrl: OPENAI_PROXY_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 272000, + maxTokens: 128000, + }, { id: "gpt-5.1-2025-11-13", name: "GPT-5.1", @@ -285,7 +325,17 @@ export function streamGitLabDuo( const innerStream = cfg.backend === "anthropic" - ? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions) + ? streamSimpleAnthropic( + { + ...(modelWithBaseUrl as Model<"anthropic-messages">), + compat: { + ...(modelWithBaseUrl as Model<"anthropic-messages">).compat, + forceAdaptiveThinking: true, + }, + }, + context, + streamOptions, + ) : streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions); for await (const event of innerStream) stream.push(event); @@ -327,12 +377,13 @@ export function streamGitLabDuo( export default function (pi: ExtensionAPI) { pi.registerProvider("gitlab-duo", { baseUrl: AI_GATEWAY_URL, - apiKey: "GITLAB_TOKEN", + apiKey: "$GITLAB_TOKEN", api: "gitlab-duo-api", - models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({ + models: MODELS.map(({ id, name, reasoning, thinkingLevelMap, input, cost, contextWindow, maxTokens }) => ({ id, name, reasoning, + thinkingLevelMap, input, cost, contextWindow, diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 2db431cf..779d519c 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.75.4", + "version": "0.79.9", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/doom-overlay/index.ts b/packages/coding-agent/examples/extensions/doom-overlay/index.ts index 9d9dad13..843d52da 100644 --- a/packages/coding-agent/examples/extensions/doom-overlay/index.ts +++ b/packages/coding-agent/examples/extensions/doom-overlay/index.ts @@ -23,7 +23,7 @@ export default function (pi: ExtensionAPI) { description: "Play DOOM as an overlay. Q to pause and exit.", handler: async (args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("DOOM requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts b/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts new file mode 100644 index 00000000..61ad1320 --- /dev/null +++ b/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts @@ -0,0 +1,115 @@ +/** + * Merge and Resolve + * + * Keeps the working branch up to date with its upstream tracking ref. + * After each agent turn, fetches and merges. Clean merges complete + * silently. When conflicts arise, the working tree is left dirty and + * the agent receives a follow-up message listing each conflict block + * with file, line range, and ours/theirs sections so it can resolve them. + * Also re-sends unresolved conflicts from a previous incomplete merge. + * + * Start pi with this extension: + * pi -e ./examples/extensions/git-merge-and-resolve.ts + */ +import { createReadStream } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +interface ConflictBlock { + file: string; + startLine: number; + separatorLine: number; + endLine: number; +} + +/** Parse conflict markers from working tree files with unmerged paths. */ +async function findConflicts(pi: ExtensionAPI, cwd: string): Promise { + const { stdout, code } = await pi.exec("git", ["diff", "--name-only", "--diff-filter=U"]); + if (code !== 0 || !stdout.trim()) return []; + + const blocks: ConflictBlock[] = []; + for (const file of stdout.trim().split("\n")) { + try { + const rl = createInterface({ input: createReadStream(join(cwd, file), "utf-8") }); + let lineNo = 0; + let blockStart: number | undefined; + let separatorLine: number | undefined; + for await (const line of rl) { + lineNo++; + if (line.startsWith("<<<<<<<")) { + blockStart = lineNo; + separatorLine = undefined; + } else if (line.startsWith("=======") && blockStart !== undefined) { + separatorLine = lineNo; + } else if (line.startsWith(">>>>>>>") && blockStart !== undefined && separatorLine !== undefined) { + blocks.push({ file, startLine: blockStart, separatorLine, endLine: lineNo }); + blockStart = undefined; + separatorLine = undefined; + } + } + } catch {} + } + return blocks; +} + +function formatRange(start: number, end: number): string { + if (start > end) return "empty"; + if (start === end) return `${start}`; + return `${start}-${end}`; +} + +function formatConflicts(ref: string, blocks: ConflictBlock[]): string { + const lines = [`Merged ${ref} with conflicts:`, ""]; + for (const b of blocks) { + const ours = formatRange(b.startLine + 1, b.separatorLine - 1); + const theirs = formatRange(b.separatorLine + 1, b.endLine - 1); + lines.push(` ${b.file}:${b.startLine}-${b.endLine} (ours ${ours}, theirs ${theirs})`); + } + lines.push("", "Resolve these conflicts."); + return lines.join("\n"); +} + +export default function (pi: ExtensionAPI) { + pi.on("agent_end", async (_event, ctx) => { + const { code: revParseCode } = await pi.exec("git", ["rev-parse", "--git-dir"]); + if (revParseCode !== 0) return; + + let ref = "MERGE_HEAD"; + + // If not already in a merge, attempt one + const { code: mergeHeadCode } = await pi.exec("git", ["rev-parse", "MERGE_HEAD"]); + if (mergeHeadCode !== 0) { + // Only attempt a new merge if the working tree is clean + const { stdout: status } = await pi.exec("git", ["status", "--porcelain"]); + if (status.trim()) return; + + const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{u}", + ]); + if (upstreamCode !== 0) return; + + ref = upstream.trim(); + const remote = ref.split("/")[0]; + ctx.ui.notify(`git-merge-and-resolve: fetching ${remote}, merging ${ref}`, "info"); + + const { code: fetchCode, stderr: fetchErr } = await pi.exec("git", ["fetch", remote]); + if (fetchCode !== 0) { + ctx.ui.notify(`git-merge-and-resolve: fetch failed: ${fetchErr.trim()}`, "warning"); + return; + } + + const { code: mergeCode } = await pi.exec("git", ["merge", "--no-ff", ref]); + if (mergeCode === 0) return; + } + + // Either we just merged with conflicts, or we were already in an unfinished merge + const conflicts = await findConflicts(pi, ctx.cwd); + if (conflicts.length === 0) return; + + pi.sendUserMessage(formatConflicts(ref, conflicts), { deliverAs: "followUp" }); + }); +} diff --git a/packages/coding-agent/examples/extensions/gondolin/.gitignore b/packages/coding-agent/examples/extensions/gondolin/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/gondolin/index.ts b/packages/coding-agent/examples/extensions/gondolin/index.ts new file mode 100644 index 00000000..ab050df7 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/index.ts @@ -0,0 +1,531 @@ +/** + * Gondolin Tool Routing Example + * + * Runs pi's built-in tools inside a local Gondolin micro-VM. The host working + * directory is mounted at /workspace in the guest. File changes under + * /workspace write through to the host; other guest filesystem changes are + * isolated to the VM. + * + * Setup: + * cd packages/coding-agent/examples/extensions/gondolin + * npm install --ignore-scripts + * + * Usage: + * cd /path/to/project + * pi -e /path/to/pi/packages/coding-agent/examples/extensions/gondolin + * + * Requirements: + * - Node.js >= 23.6.0 for @earendil-works/gondolin + * - QEMU installed (for example, `brew install qemu` on macOS) + */ + +import path from "node:path"; +import { RealFSProvider, VM } from "@earendil-works/gondolin"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + type BashOperations, + createBashTool, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadTool, + createWriteTool, + DEFAULT_MAX_BYTES, + type EditOperations, + type FindOperations, + formatSize, + type GrepToolDetails, + type GrepToolInput, + type LsOperations, + type ReadOperations, + truncateHead, + truncateLine, + type WriteOperations, +} from "@earendil-works/pi-coding-agent"; + +const GUEST_WORKSPACE = "/workspace"; +const DEFAULT_GREP_LIMIT = 100; + +type TextToolResult = { + content: Array<{ type: "text"; text: string }>; + details: TDetails | undefined; +}; + +function stripAtPrefix(value: string): string { + return value.startsWith("@") ? value.slice(1) : value; +} + +function toPosix(value: string): string { + return value.split(path.sep).join(path.posix.sep); +} + +function isInsideHostPath(root: string, value: string): boolean { + const relativePath = path.relative(root, value); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function hostPathToGuest(localCwd: string, hostPath: string): string { + const relativePath = path.relative(localCwd, hostPath); + if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath); + return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE; +} + +function toGuestPath(localCwd: string, inputPath: string): string { + const trimmed = stripAtPrefix(inputPath.trim()); + if (!trimmed) return GUEST_WORKSPACE; + if (path.isAbsolute(trimmed)) { + if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed); + return path.posix.resolve("/", toPosix(trimmed)); + } + return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); +} + +function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { + return { + readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)), + access: async (filePath) => { + await vm.fs.access(toGuestPath(localCwd, filePath)); + }, + detectImageMimeType: async (filePath) => { + const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase(); + if (ext === ".png") return "image/png"; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".gif") return "image/gif"; + if (ext === ".webp") return "image/webp"; + return null; + }, + }; +} + +function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { + return { + writeFile: async (filePath, content) => { + await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" }); + }, + mkdir: async (dirPath) => { + await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); + }, + }; +} + +function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { + const readOps = createGondolinReadOps(vm, localCwd); + const writeOps = createGondolinWriteOps(vm, localCwd); + return { + readFile: readOps.readFile, + writeFile: writeOps.writeFile, + access: readOps.access, + }; +} + +function createGondolinLsOps(vm: VM, localCwd: string): LsOperations { + return { + exists: async (filePath) => { + try { + await vm.fs.access(toGuestPath(localCwd, filePath)); + return true; + } catch { + return false; + } + }, + stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), + readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), + }; +} + +async function walkGuestFiles( + vm: VM, + root: string, + visit: (guestPath: string, relativePath: string) => Promise, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new Error("Operation aborted"); + const stat = await vm.fs.stat(root, { signal }); + if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); + + const walkDirectory = async (dir: string, relativeDir: string): Promise => { + if (signal?.aborted) throw new Error("Operation aborted"); + const entries = await vm.fs.listDir(dir, { signal }); + for (const entry of entries) { + if (entry === ".git" || entry === "node_modules") continue; + const guestPath = path.posix.join(dir, entry); + const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry; + let entryStat: Awaited>; + try { + entryStat = await vm.fs.stat(guestPath, { signal }); + } catch { + continue; + } + if (entryStat.isDirectory()) { + if (!(await walkDirectory(guestPath, relativePath))) return false; + } else if (!(await visit(guestPath, relativePath))) { + return false; + } + } + return true; + }; + + return walkDirectory(root, ""); +} + +function matchesToolGlob(relativePath: string, pattern: string): boolean { + const normalizedPattern = toPosix(pattern); + if (normalizedPattern.includes("/")) { + return ( + path.posix.matchesGlob(relativePath, normalizedPattern) || + path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) + ); + } + return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern); +} + +function createGondolinFindOps(vm: VM, localCwd: string): FindOperations { + return { + exists: async (filePath) => { + try { + await vm.fs.access(toGuestPath(localCwd, filePath)); + return true; + } catch { + return false; + } + }, + glob: async (pattern, cwd, options) => { + const root = toGuestPath(localCwd, cwd); + const results: string[] = []; + await walkGuestFiles(vm, root, async (guestPath, relativePath) => { + if (results.length >= options.limit) return false; + if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); + return results.length < options.limit; + }); + return results; + }, + }; +} + +function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) { + if (literal) { + const needle = ignoreCase ? pattern.toLowerCase() : pattern; + return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle); + } + const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); + return (line: string) => regex.test(line); +} + +function appendGrepBlock(params: { + outputLines: string[]; + lines: string[]; + relativePath: string; + lineIndex: number; + contextLines: number; +}): boolean { + let linesTruncated = false; + const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex; + const end = + params.contextLines > 0 + ? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines) + : params.lineIndex; + + for (let index = start; index <= end; index++) { + const rawLine = params.lines[index] ?? ""; + const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); + if (wasTruncated) linesTruncated = true; + const separator = index === params.lineIndex ? ":" : "-"; + params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`); + } + return linesTruncated; +} + +async function executeGondolinGrep( + vm: VM, + localCwd: string, + params: GrepToolInput, + signal?: AbortSignal, +): Promise> { + const root = toGuestPath(localCwd, params.path ?? "."); + const rootStat = await vm.fs.stat(root, { signal }); + const rootIsDirectory = rootStat.isDirectory(); + const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase); + const contextLines = params.context && params.context > 0 ? params.context : 0; + const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); + const outputLines: string[] = []; + const details: GrepToolDetails = {}; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + + await walkGuestFiles( + vm, + root, + async (guestPath, relativePath) => { + if (matchCount >= effectiveLimit) return false; + if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true; + let content: string; + try { + content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); + } catch { + return true; + } + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath); + for (let index = 0; index < lines.length; index++) { + if (signal?.aborted) throw new Error("Operation aborted"); + if (!matcher(lines[index] ?? "")) continue; + matchCount++; + if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) { + linesTruncated = true; + } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + return false; + } + } + return true; + }, + signal, + ); + + if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; + + const rawOutput = outputLines.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + const notices: string[] = []; + let output = truncation.content; + + if (matchLimitReached) { + details.matchLimitReached = effectiveLimit; + notices.push(`${effectiveLimit} matches limit reached`); + } + if (linesTruncated) { + details.linesTruncated = true; + notices.push("long lines truncated"); + } + if (truncation.truncated) { + details.truncation = truncation; + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; +} + +function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { + if (!env) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + +function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations { + return { + exec: async (command, cwd, { onData, signal, timeout, env }) => { + if (signal?.aborted) throw new Error("aborted"); + const guestCwd = toGuestPath(localCwd, cwd); + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + + let timedOut = false; + const timer = + timeout && timeout > 0 + ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout * 1000) + : undefined; + + try { + const proc = vm.exec([shellPath, "-lc", command], { + cwd: guestCwd, + env: sanitizeEnv(env), + signal: controller.signal, + stdout: "pipe", + stderr: "pipe", + }); + for await (const chunk of proc.output()) onData(chunk.data); + const result = await proc; + return { exitCode: result.exitCode }; + } catch (error) { + if (signal?.aborted) throw new Error("aborted"); + if (timedOut) throw new Error(`timeout:${timeout}`); + throw error; + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + }, + }; +} + +export default function (pi: ExtensionAPI) { + const localCwd = process.cwd(); + const localRead = createReadTool(localCwd); + const localWrite = createWriteTool(localCwd); + const localEdit = createEditTool(localCwd); + const localBash = createBashTool(localCwd); + const localGrep = createGrepTool(localCwd); + const localFind = createFindTool(localCwd); + const localLs = createLsTool(localCwd); + + let vm: VM | undefined; + let vmStarting: Promise | undefined; + let shellPath = "/bin/sh"; + + async function startVm(ctx?: ExtensionContext): Promise { + ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`)); + const created = await VM.create({ + sessionLabel: `pi ${path.basename(localCwd)}`, + vfs: { + mounts: { + [GUEST_WORKSPACE]: new RealFSProvider(localCwd), + }, + }, + }); + const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]); + shellPath = bashProbe.stdout.trim() || "/bin/sh"; + vm = created; + ctx?.ui.setStatus( + "gondolin", + ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`), + ); + ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info"); + return created; + } + + async function ensureVm(ctx?: ExtensionContext): Promise { + if (vm) return vm; + if (!vmStarting) { + vmStarting = startVm(ctx).finally(() => { + vmStarting = undefined; + }); + } + return vmStarting; + } + + pi.on("session_start", async (_event, ctx) => { + await ensureVm(ctx); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + const activeVm = vm; + vm = undefined; + vmStarting = undefined; + if (!activeVm) return; + ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping")); + try { + await activeVm.close(); + } finally { + ctx.ui.setStatus("gondolin", undefined); + } + }); + + pi.registerCommand("gondolin", { + description: "Show Gondolin VM status", + handler: async (_args, ctx) => { + const activeVm = await ensureVm(ctx); + ctx.ui.notify( + [ + `Gondolin VM: ${activeVm.id}`, + `Host workspace: ${localCwd}`, + `Guest workspace: ${GUEST_WORKSPACE}`, + `Shell: ${shellPath}`, + ].join("\n"), + "info", + ); + }, + }); + + pi.registerTool({ + ...localRead, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createReadTool(GUEST_WORKSPACE, { + operations: createGondolinReadOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localWrite, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createWriteTool(GUEST_WORKSPACE, { + operations: createGondolinWriteOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localEdit, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createEditTool(GUEST_WORKSPACE, { + operations: createGondolinEditOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localBash, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createBashTool(GUEST_WORKSPACE, { + operations: createGondolinBashOps(activeVm, localCwd, shellPath), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localLs, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createLsTool(GUEST_WORKSPACE, { + operations: createGondolinLsOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localFind, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createFindTool(GUEST_WORKSPACE, { + operations: createGondolinFindOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localGrep, + async execute(_id, params, signal, _onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + return executeGondolinGrep(activeVm, localCwd, params, signal); + }, + }); + + pi.on("user_bash", async (_event, ctx) => { + const activeVm = await ensureVm(ctx); + return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; + }); + + pi.on("before_agent_start", async (event, ctx) => { + await ensureVm(ctx); + const localLine = `Current working directory: ${localCwd}`; + const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; + const systemPrompt = event.systemPrompt.includes(localLine) + ? event.systemPrompt.replace(localLine, guestLine) + : `${event.systemPrompt}\n\n${guestLine}`; + return { systemPrompt }; + }); +} diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json new file mode 100644 index 00000000..01ee2f61 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -0,0 +1,185 @@ +{ + "name": "pi-extension-gondolin", + "version": "0.79.9", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-gondolin", + "version": "0.79.9", + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } + }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", + "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@earendil-works/gondolin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", + "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "license": "Apache-2.0", + "dependencies": { + "cbor2": "^2.3.0", + "node-forge": "^1.3.3", + "ssh2": "^1.17.0", + "undici": "^6.21.0" + }, + "bin": { + "gondolin": "dist/bin/gondolin.js" + }, + "engines": { + "node": ">=23.6.0" + }, + "optionalDependencies": { + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", + "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", + "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cbor2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", + "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", + "license": "MIT", + "dependencies": { + "@cto.af/wtf8": "0.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json new file mode 100644 index 00000000..67a9ac1e --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-extension-gondolin", + "private": true, + "version": "0.79.9", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } +} diff --git a/packages/coding-agent/examples/extensions/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts index 10581dc6..4af6661f 100644 --- a/packages/coding-agent/examples/extensions/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -81,7 +81,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("handoff", { description: "Transfer context to a new focused session", handler: async (args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("handoff requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/input-transform-streaming.ts b/packages/coding-agent/examples/extensions/input-transform-streaming.ts new file mode 100644 index 00000000..65d805a5 --- /dev/null +++ b/packages/coding-agent/examples/extensions/input-transform-streaming.ts @@ -0,0 +1,39 @@ +/** + * Streaming-Aware Input Gate + * + * Demonstrates `event.streamingBehavior` to skip expensive pre-processing + * during mid-stream steering, where low latency matters. + * + * This extension prepends `git diff --stat` output when the user mentions + * file changes, giving the model immediate context. During steering the + * exec call is skipped so the correction reaches the model without delay. + * + * Start pi with this extension: + * pi -e ./examples/extensions/input-transform-streaming.ts + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const TRIGGER = /\b(changes?|diff|modified)\b/i; + +export default function (pi: ExtensionAPI) { + pi.on("input", async (event) => { + // During steering, skip the exec call — corrections should be fast + if (event.streamingBehavior === "steer") { + return { action: "continue" }; + } + + if (!TRIGGER.test(event.text)) { + return { action: "continue" }; + } + + const { stdout, code } = await pi.exec("git", ["diff", "--stat"]); + if (code !== 0 || !stdout.trim()) { + return { action: "continue" }; + } + + return { + action: "transform", + text: `${event.text}\n\nCurrent uncommitted changes:\n\`\`\`\n${stdout.trim()}\n\`\`\``, + }; + }); +} diff --git a/packages/coding-agent/examples/extensions/interactive-shell.ts b/packages/coding-agent/examples/extensions/interactive-shell.ts index 99a7f963..b1dd3746 100644 --- a/packages/coding-agent/examples/extensions/interactive-shell.ts +++ b/packages/coding-agent/examples/extensions/interactive-shell.ts @@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) { } // No UI available (print mode, RPC, etc.) - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return { result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false }, }; diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index 306c139b..fd4486d8 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -15,13 +15,13 @@ * /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols) * /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden) * /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay) - * /overlay-focus - Focus cycling and rendering order with non-capturing overlays + * /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays * /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus) */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui"; -import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; // Global handle for toggle demo (in real code, use a more elegant pattern) @@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) { }, }); - // Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order + // Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order pi.registerCommand("overlay-focus", { - description: "Test focus cycling and rendering order with non-capturing overlays", + description: "Test focus cycling, input routing, dismissal, and rendering order with overlays", handler: async (_args: string, ctx: ExtensionCommandContext) => { ctx.ui.setEditorText(""); await ctx.ui.custom((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), { @@ -1031,57 +1031,66 @@ class TimerPanel extends BaseOverlay { // === Focus cycling demo === +type FocusPanelColor = "error" | "success" | "accent"; +type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions }; +type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle }; + +const FOCUS_PANEL_CONFIGS = [ + { label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } }, + { label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } }, + { label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } }, +] satisfies FocusPanelConfig[]; + class FocusDemoController extends BaseOverlay { - private tui: TUI; - private panels: FocusPanel[] = []; - private handles: OverlayHandle[] = []; - private focusIndex = -1; - private done: () => void; + private readonly tui: TUI; + private entries: FocusPanelEntry[] = []; + private readonly done: () => void; + private closed = false; constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); this.tui = tui; this.done = done; - const colors = ["error", "success", "accent"] as const; - const labels = ["Alpha", "Beta", "Gamma"]; - for (let i = 0; i < 3; i++) { - const panel = new FocusPanel( - theme, - labels[i]!, - colors[i]!, - () => this.cycleFocus(), - () => this.close(), - ); - const handle = this.tui.showOverlay(panel, { - nonCapturing: true, - row: 2, - col: 5 + i * 6, - width: 28, - }); - panel.handle = handle; - this.panels.push(panel); - this.handles.push(handle); + for (const config of FOCUS_PANEL_CONFIGS) { + const panel = new FocusPanel({ theme, config, controller: this }); + const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options }); + this.entries.push({ panel, handle }); } + + this.focusFirstOpenPanel(); } - private cycleFocus(): void { - if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) { - this.handles[this.focusIndex]!.unfocus(); - } - this.focusIndex++; - if (this.focusIndex >= this.handles.length) { - this.focusIndex = -1; - } else { - this.handles[this.focusIndex]!.focus(); - } - this.tui.requestRender(); + focusNext(current: FocusPanel, direction: 1 | -1 = 1): void { + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current); + if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`); + const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length; + this.focusEntryAt(openEntries, nextOpenPosition); } - private close(): void { - for (const handle of this.handles) handle.hide(); - this.handles = []; - this.panels = []; + dismiss(panel: FocusPanel): void { + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel); + if (currentOpenPosition === -1) return; + const entry = openEntries[currentOpenPosition]; + if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`); + const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel); + + entry.panel.closed = true; + entry.handle.hide(); + if (remainingEntries.length === 0) { + this.close(); + return; + } + + this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.hidePanels(); this.done(); } @@ -1089,86 +1098,148 @@ class FocusDemoController extends BaseOverlay { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); } else if (matchesKey(data, "tab")) { - this.cycleFocus(); + this.focusFirstOpenPanel(); } } render(width: number): string[] { const th = this.theme; - const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?"); + const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller"; return this.box( [ "", ` Current focus: ${th.fg("accent", focused)}`, "", " Three overlapping panels above are", - ` all ${th.fg("accent", "nonCapturing")}. Press Tab to`, - " cycle focus() between them.", + ` ${th.fg("accent", "nonCapturing")} overlays controlled with`, + " raw OverlayHandle.focus()/hide().", "", - " Focused panel renders on top", - " (focus-based rendering order).", + " Type in the focused panel's input.", + " Focused panel renders on top.", "", - th.fg("dim", " Tab = cycle focus | Esc = close"), + th.fg("dim", " Tab/Shift+Tab = cycle panels"), + th.fg("dim", " Esc/Ctrl+D = dismiss panel"), + th.fg("dim", " Ctrl+C = close all"), "", ], width, - "Focus Demo", + "Focus + Input Demo", ); } override dispose(): void { - for (const handle of this.handles) handle.hide(); + if (this.closed) return; + this.closed = true; + this.hidePanels(); + } + + private focusFirstOpenPanel(): void { + const firstOpen = this.openEntries()[0]; + if (firstOpen) { + firstOpen.handle.focus(); + this.tui.requestRender(); + } + } + + private focusEntryAt(entries: FocusPanelEntry[], index: number): void { + const entry = entries[index]; + if (!entry) throw new Error(`Invalid focus panel index ${index}`); + entry.handle.focus(); + this.tui.requestRender(); + } + + private hidePanels(): void { + for (const entry of this.entries) { + if (!entry.panel.closed) { + entry.panel.closed = true; + entry.handle.hide(); + } + } + this.entries = []; + } + + private openEntries(): FocusPanelEntry[] { + return this.entries.filter((entry) => !entry.panel.closed); } } class FocusPanel extends BaseOverlay { - handle: OverlayHandle | null = null; + focused = false; + closed = false; readonly label: string; - private color: "error" | "success" | "accent"; - private onTab: () => void; - private onClose: () => void; + private readonly color: FocusPanelColor; + private readonly controller: FocusDemoController; + private readonly input = new Input(); + private inputs: string[] = []; - constructor( - theme: Theme, - label: string, - color: "error" | "success" | "accent", - onTab: () => void, - onClose: () => void, - ) { + constructor({ + theme, + config, + controller, + }: { + theme: Theme; + config: FocusPanelConfig; + controller: FocusDemoController; + }) { super(theme); - this.label = label; - this.color = color; - this.onTab = onTab; - this.onClose = onClose; + this.label = config.label; + this.color = config.color; + this.controller = controller; } handleInput(data: string): void { if (matchesKey(data, "tab")) { - this.onTab(); - } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { - this.onClose(); + this.controller.focusNext(this); + } else if (matchesKey(data, "shift+tab")) { + this.controller.focusNext(this, -1); + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) { + this.controller.dismiss(this); + } else if (matchesKey(data, "ctrl+c")) { + this.controller.close(); + } else if (matchesKey(data, "return")) { + this.inputs.push("Enter"); + } else if (matchesKey(data, "up")) { + this.inputs.push("↑"); + } else if (matchesKey(data, "down")) { + this.inputs.push("↓"); + } else if (matchesKey(data, "left")) { + this.input.handleInput(data); + this.inputs.push("←"); + } else if (matchesKey(data, "right")) { + this.input.handleInput(data); + this.inputs.push("→"); + } else if (matchesKey(data, "backspace")) { + this.input.handleInput(data); + this.inputs.push("Backspace"); + } else { + this.input.handleInput(data); + this.inputs.push(JSON.stringify(data)); } } render(width: number): string[] { const th = this.theme; - const focused = this.handle?.isFocused() ?? false; const innerW = Math.max(1, width - 2); - const border = (c: string) => th.fg(this.color, c); + const border = (c: string) => th.fg(this.focused ? this.color : "dim", c); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); + const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" "); const lines: string[] = []; + this.input.focused = this.focused; + const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8)); lines.push(border(`╭${"─".repeat(innerW)}╮`)); - lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│")); - lines.push(border("│") + padLine("") + border("│")); - if (focused) { - lines.push(border("│") + padLine(th.fg("success", " ● FOCUSED")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│")); - } else { - lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│")); - } + lines.push( + border("│") + + padLine( + ` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`, + ) + + border("│"), + ); lines.push(border("│") + padLine("") + border("│")); + lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│")); + lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`)); return lines; diff --git a/packages/coding-agent/examples/extensions/preset.ts b/packages/coding-agent/examples/extensions/preset.ts index 92224ec2..b78237ca 100644 --- a/packages/coding-agent/examples/extensions/preset.ts +++ b/packages/coding-agent/examples/extensions/preset.ts @@ -42,7 +42,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { Api, Model } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent"; import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; // Preset configuration @@ -69,7 +69,7 @@ interface PresetsConfig { */ function loadPresets(cwd: string): PresetsConfig { const globalPath = join(getAgentDir(), "presets.json"); - const projectPath = join(cwd, ".pi", "presets.json"); + const projectPath = join(cwd, CONFIG_DIR_NAME, "presets.json"); let globalPresets: PresetsConfig = {}; let projectPresets: PresetsConfig = {}; @@ -200,7 +200,10 @@ export default function presetExtension(pi: ExtensionAPI) { const presetNames = Object.keys(presets); if (presetNames.length === 0) { - ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + ctx.ui.notify( + `No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`, + "warning", + ); return; } @@ -308,7 +311,10 @@ export default function presetExtension(pi: ExtensionAPI) { async function cyclePreset(ctx: ExtensionContext): Promise { const presetNames = getPresetOrder(); if (presetNames.length === 0) { - ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + ctx.ui.notify( + `No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`, + "warning", + ); return; } diff --git a/packages/coding-agent/examples/extensions/project-trust.ts b/packages/coding-agent/examples/extensions/project-trust.ts new file mode 100644 index 00000000..6234bc24 --- /dev/null +++ b/packages/coding-agent/examples/extensions/project-trust.ts @@ -0,0 +1,64 @@ +/** + * Project Trust Extension + * + * Demonstrates the project_trust event. Install globally or pass via -e: + * + * mkdir -p ~/.pi/agent/extensions + * cp packages/coding-agent/examples/extensions/project-trust.ts ~/.pi/agent/extensions/ + * + * Or: + * + * pi -e packages/coding-agent/examples/extensions/project-trust.ts + * + * Try it in a project containing .pi, AGENTS.md/CLAUDE.md, or .agents/skills. + */ + +import type { ExtensionAPI, ProjectTrustEventResult } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + let loadCount = 0; + loadCount++; + + // Multiple handlers in one extension are allowed. The first handler that returns + // { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in + // trust prompt. Return { trusted: "undecided" } to let another handler or the + // built-in flow decide. + pi.on("project_trust", async (event, ctx): Promise => { + ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info"); + + if (!ctx.hasUI) { + return { trusted: "undecided" }; + } + + const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [ + "Trust and remember", + "Trust with note and remember", + "Trust this session", + "Do not trust this session", + "Let built-in prompt decide", + ]); + + if (choice === "Trust with note and remember") { + const note = await ctx.ui.input("Project trust note", "Optional note for this demo"); + ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info"); + return { trusted: "yes", remember: true }; + } + if (choice === "Trust and remember") { + return { trusted: "yes", remember: true }; + } + if (choice === "Trust this session") { + return { trusted: "yes" }; + } + if (choice === "Do not trust this session") { + return { trusted: "no" }; + } + if (choice === "Let built-in prompt decide") { + return { trusted: "undecided" }; + } + return { trusted: "undecided" }; + }); + + pi.on("session_start", (_event, ctx) => { + ctx.ui.notify(`project-trust example loaded after trust resolution in ${ctx.cwd}`, "info"); + }); +} diff --git a/packages/coding-agent/examples/extensions/provider-payload.ts b/packages/coding-agent/examples/extensions/provider-payload.ts index 860ddc00..7f02a077 100644 --- a/packages/coding-agent/examples/extensions/provider-payload.ts +++ b/packages/coding-agent/examples/extensions/provider-payload.ts @@ -1,18 +1,18 @@ import { appendFileSync } from "node:fs"; import { join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { - const logFile = join(process.cwd(), ".pi", "provider-payload.log"); - - pi.on("before_provider_request", (event) => { + pi.on("before_provider_request", (event, ctx) => { + const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log"); appendFileSync(logFile, `${JSON.stringify(event.payload, null, 2)}\n\n`, "utf8"); // Optional: replace the payload instead of only logging it. // return { ...event.payload, temperature: 0 }; }); - pi.on("after_provider_response", (event) => { + pi.on("after_provider_response", (event, ctx) => { + const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log"); appendFileSync(logFile, `[${event.status}] ${JSON.stringify(event.headers)}\n\n`, "utf8"); }); } diff --git a/packages/coding-agent/examples/extensions/qna.ts b/packages/coding-agent/examples/extensions/qna.ts index 2675b5d7..70dbef7b 100644 --- a/packages/coding-agent/examples/extensions/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -31,7 +31,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("qna", { description: "Extract questions from last assistant message into editor", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("qna requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index 7aba1d70..1192b490 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -5,7 +5,15 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { + Editor, + type EditorTheme, + Key, + matchesKey, + Text, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; import { Type } from "typebox"; interface OptionWithDesc { @@ -41,7 +49,7 @@ export default function question(pi: ExtensionAPI) { parameters: QuestionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], details: { @@ -139,10 +147,27 @@ export default function question(pi: ExtensionAPI) { if (cachedLines) return cachedLines; const lines: string[] = []; - const add = (s: string) => lines.push(truncateToWidth(s, width)); + const renderWidth = Math.max(1, width); - add(theme.fg("accent", "─".repeat(width))); - add(theme.fg("text", ` ${params.question}`)); + function addWrapped(text: string) { + lines.push(...wrapTextWithAnsi(text, renderWidth)); + } + + function addWrappedWithPrefix(prefix: string, text: string) { + const prefixWidth = visibleWidth(prefix); + if (prefixWidth >= renderWidth) { + addWrapped(prefix + text); + return; + } + const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); + const continuationPrefix = " ".repeat(prefixWidth); + for (let i = 0; i < wrapped.length; i++) { + lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); + } + } + + lines.push(theme.fg("accent", "─".repeat(renderWidth))); + addWrappedWithPrefix(" ", theme.fg("text", params.question)); lines.push(""); for (let i = 0; i < allOptions.length; i++) { @@ -150,36 +175,32 @@ export default function question(pi: ExtensionAPI) { const selected = i === optionIndex; const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; + const label = `${i + 1}. ${opt.label}${isOther && editMode ? " ✎" : ""}`; + const color = selected || (isOther && editMode) ? "accent" : "text"; - if (isOther && editMode) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); - } else if (selected) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`)); - } else { - add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`); - } + addWrappedWithPrefix(prefix, theme.fg(color, label)); // Show description if present if (opt.description) { - add(` ${theme.fg("muted", opt.description)}`); + addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } if (editMode) { lines.push(""); - add(theme.fg("muted", " Your answer:")); - for (const line of editor.render(width - 2)) { - add(` ${line}`); + addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); + for (const line of editor.render(Math.max(1, renderWidth - 2))) { + lines.push(` ${line}`); } } lines.push(""); if (editMode) { - add(theme.fg("dim", " Enter to submit • Esc to go back")); + addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to go back")); } else { - add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel")); + addWrappedWithPrefix(" ", theme.fg("dim", "↑↓ navigate • Enter to select • Esc to cancel")); } - add(theme.fg("accent", "─".repeat(width))); + lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; diff --git a/packages/coding-agent/examples/extensions/questionnaire.ts b/packages/coding-agent/examples/extensions/questionnaire.ts index 34fb666c..3a546ac3 100644 --- a/packages/coding-agent/examples/extensions/questionnaire.ts +++ b/packages/coding-agent/examples/extensions/questionnaire.ts @@ -6,7 +6,15 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { + Editor, + type EditorTheme, + Key, + matchesKey, + Text, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; import { Type } from "typebox"; // Types @@ -82,7 +90,7 @@ export default function questionnaire(pi: ExtensionAPI) { parameters: QuestionnaireParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return errorResult("Error: UI not available (running in non-interactive mode)"); } if (params.questions.length === 0) { @@ -259,13 +267,28 @@ export default function questionnaire(pi: ExtensionAPI) { if (cachedLines) return cachedLines; const lines: string[] = []; + const renderWidth = Math.max(1, width); const q = currentQuestion(); const opts = currentOptions(); - // Helper to add truncated line - const add = (s: string) => lines.push(truncateToWidth(s, width)); + function addWrapped(text: string) { + lines.push(...wrapTextWithAnsi(text, renderWidth)); + } - add(theme.fg("accent", "─".repeat(width))); + function addWrappedWithPrefix(prefix: string, text: string) { + const prefixWidth = visibleWidth(prefix); + if (prefixWidth >= renderWidth) { + addWrapped(prefix + text); + return; + } + const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); + const continuationPrefix = " ".repeat(prefixWidth); + for (let i = 0; i < wrapped.length; i++) { + lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); + } + } + + lines.push(theme.fg("accent", "─".repeat(renderWidth))); // Tab bar (multi-question only) if (isMulti) { @@ -287,7 +310,7 @@ export default function questionnaire(pi: ExtensionAPI) { ? theme.bg("selectedBg", theme.fg("text", submitText)) : theme.fg(canSubmit ? "success" : "dim", submitText); tabs.push(`${submitStyled} →`); - add(` ${tabs.join("")}`); + addWrappedWithPrefix(" ", tabs.join("")); lines.push(""); } @@ -298,54 +321,52 @@ export default function questionnaire(pi: ExtensionAPI) { const selected = i === optionIndex; const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; - const color = selected ? "accent" : "text"; - // Mark "Type something" differently when in input mode - if (isOther && inputMode) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); - } else { - add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`)); - } + const label = `${i + 1}. ${opt.label}${isOther && inputMode ? " ✎" : ""}`; + const color = selected || (isOther && inputMode) ? "accent" : "text"; + + addWrappedWithPrefix(prefix, theme.fg(color, label)); if (opt.description) { - add(` ${theme.fg("muted", opt.description)}`); + addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } } // Content if (inputMode && q) { - add(theme.fg("text", ` ${q.prompt}`)); + addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); // Show options for reference renderOptions(); lines.push(""); - add(theme.fg("muted", " Your answer:")); - for (const line of editor.render(width - 2)) { - add(` ${line}`); + addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); + for (const line of editor.render(Math.max(1, renderWidth - 2))) { + lines.push(` ${line}`); } lines.push(""); - add(theme.fg("dim", " Enter to submit • Esc to cancel")); + addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel")); } else if (currentTab === questions.length) { - add(theme.fg("accent", theme.bold(" Ready to submit"))); + addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit"))); lines.push(""); for (const question of questions) { const answer = answers.get(question.id); if (answer) { const prefix = answer.wasCustom ? "(wrote) " : ""; - add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`); + const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`; + addWrappedWithPrefix(" ", summary); } } lines.push(""); if (allAnswered()) { - add(theme.fg("success", " Press Enter to submit")); + addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit")); } else { const missing = questions .filter((q) => !answers.has(q.id)) .map((q) => q.label) .join(", "); - add(theme.fg("warning", ` Unanswered: ${missing}`)); + addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`)); } } else if (q) { - add(theme.fg("text", ` ${q.prompt}`)); + addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); renderOptions(); } @@ -353,11 +374,11 @@ export default function questionnaire(pi: ExtensionAPI) { lines.push(""); if (!inputMode) { const help = isMulti - ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" - : " ↑↓ navigate • Enter select • Esc cancel"; - add(theme.fg("dim", help)); + ? "Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" + : "↑↓ navigate • Enter select • Esc cancel"; + addWrappedWithPrefix(" ", theme.fg("dim", help)); } - add(theme.fg("accent", "─".repeat(width))); + lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; @@ -400,7 +421,7 @@ export default function questionnaire(pi: ExtensionAPI) { let text = theme.fg("toolTitle", theme.bold("questionnaire ")); text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); if (labels) { - text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`); + text += theme.fg("dim", ` (${labels})`); } return new Text(text, 0, 0); }, diff --git a/packages/coding-agent/examples/extensions/sandbox/index.ts b/packages/coding-agent/examples/extensions/sandbox/index.ts index 94f8f1cf..b54d75d1 100644 --- a/packages/coding-agent/examples/extensions/sandbox/index.ts +++ b/packages/coding-agent/examples/extensions/sandbox/index.ts @@ -46,7 +46,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { type BashOperations, CONFIG_DIR_NAME, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent"; interface SandboxConfig extends SandboxRuntimeConfig { enabled?: boolean; @@ -77,7 +77,7 @@ const DEFAULT_CONFIG: SandboxConfig = { }; function loadConfig(cwd: string): SandboxConfig { - const projectConfigPath = join(cwd, ".pi", "sandbox.json"); + const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json"); const globalConfigPath = join(getAgentDir(), "extensions", "sandbox.json"); let globalConfig: Partial = {}; diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index ef29b150..01aad382 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.9.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.9.9", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index c33d2d60..b8dc74ab 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.5.4", + "version": "1.9.9", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/snake.ts b/packages/coding-agent/examples/extensions/snake.ts index 29995b36..1b58edf9 100644 --- a/packages/coding-agent/examples/extensions/snake.ts +++ b/packages/coding-agent/examples/extensions/snake.ts @@ -311,7 +311,7 @@ export default function (pi: ExtensionAPI) { description: "Play Snake!", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Snake requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/space-invaders.ts b/packages/coding-agent/examples/extensions/space-invaders.ts index c91071b8..306c5ac5 100644 --- a/packages/coding-agent/examples/extensions/space-invaders.ts +++ b/packages/coding-agent/examples/extensions/space-invaders.ts @@ -529,7 +529,7 @@ export default function (pi: ExtensionAPI) { description: "Play Space Invaders!", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Space Invaders requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts index eab6c630..c41ef579 100644 --- a/packages/coding-agent/examples/extensions/subagent/agents.ts +++ b/packages/coding-agent/examples/extensions/subagent/agents.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; export type AgentScope = "user" | "project" | "both"; @@ -85,7 +85,7 @@ function isDirectory(p: string): boolean { function findNearestProjectAgentsDir(cwd: string): string | null { let currentDir = cwd; while (true) { - const candidate = path.join(currentDir, ".pi", "agents"); + const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents"); if (isDirectory(candidate)) return candidate; const parentDir = path.dirname(currentDir); diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index 92b471a3..832dcc74 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -19,7 +19,13 @@ import * as path from "node:path"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; -import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { + CONFIG_DIR_NAME, + type ExtensionAPI, + getAgentDir, + getMarkdownTheme, + withFileMutationQueue, +} from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; @@ -458,8 +464,8 @@ export default function (pi: ExtensionAPI) { description: [ "Delegate tasks to specialized subagents with isolated context.", "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", - 'Default agent scope is "user" (from ~/.pi/agent/agents).', - 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`, + `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`, ].join(" "), parameters: SubagentParams, diff --git a/packages/coding-agent/examples/extensions/summarize.ts b/packages/coding-agent/examples/extensions/summarize.ts index dd30c350..e6480974 100644 --- a/packages/coding-agent/examples/extensions/summarize.ts +++ b/packages/coding-agent/examples/extensions/summarize.ts @@ -115,7 +115,7 @@ const buildSummaryPrompt = (conversationText: string): string => ].join("\n"); const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return; } diff --git a/packages/coding-agent/examples/extensions/tic-tac-toe.ts b/packages/coding-agent/examples/extensions/tic-tac-toe.ts index 8cbfdeab..8077a57f 100644 --- a/packages/coding-agent/examples/extensions/tic-tac-toe.ts +++ b/packages/coding-agent/examples/extensions/tic-tac-toe.ts @@ -779,7 +779,7 @@ Decide the target cell first, then dump every action for the turn in one go. description: "Play tic-tac-toe against the agent", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Tic-tac-toe requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts index 89b147c8..67cba742 100644 --- a/packages/coding-agent/examples/extensions/todo.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -284,7 +284,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("todos", { description: "Show all todos on the current branch", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("/todos requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/tools.ts b/packages/coding-agent/examples/extensions/tools.ts index 5e617ab3..8db496a5 100644 --- a/packages/coding-agent/examples/extensions/tools.ts +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -67,6 +67,11 @@ export default function toolsExtension(pi: ExtensionAPI) { pi.registerCommand("tools", { description: "Enable/disable tools", handler: async (_args, ctx) => { + if (ctx.mode !== "tui") { + ctx.ui.notify("/tools requires TUI mode", "error"); + return; + } + // Refresh tool list allTools = pi.getAllTools(); diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 526356f4..89961b12 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.79.9", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 45f1d3f2..3ed465d2 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.75.4", + "version": "0.79.9", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 85a22b1a..c311b556 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.79.9", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -23,12 +23,13 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" @@ -473,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.9.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,14 +488,16 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.9.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", @@ -509,12 +512,12 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.9.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -545,21 +548,21 @@ "hasInstallScript": true }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", "license": "MIT", "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" }, "engines": { "node": ">= 10" @@ -567,9 +570,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -583,9 +586,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -596,9 +599,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", "license": "MIT", "engines": { "node": ">= 10" @@ -612,9 +615,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", "license": "MIT", "engines": { "node": ">= 10" @@ -625,12 +628,15 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -641,12 +647,15 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", "license": "MIT", "engines": { "node": ">= 10" @@ -657,12 +666,15 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", "license": "MIT", "engines": { "node": ">= 10" @@ -673,12 +685,15 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -689,12 +704,15 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -708,9 +726,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", "license": "MIT", "engines": { "node": ">= 10" @@ -724,14 +742,23 @@ "optional": true }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -746,6 +773,24 @@ } ] }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -765,9 +810,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { @@ -785,12 +830,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1382,15 +1421,15 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/minimatch": { @@ -1568,23 +1607,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1620,6 +1658,18 @@ } ] }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1678,9 +1728,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -1717,9 +1767,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "peerDependencies": { "bufferutil": "^4.0.1", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0c19d304..490a7751 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.79.9", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -15,23 +15,20 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./hooks": { - "types": "./dist/core/hooks/index.d.ts", - "import": "./dist/core/hooks/index.js" } }, "files": [ "dist", "docs", "examples", + "containerization.md", "CHANGELOG.md", "npm-shrinkwrap.json" ], "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", @@ -39,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -53,8 +50,9 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "overrides": { @@ -64,7 +62,7 @@ } }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" }, "devDependencies": { "@types/cross-spawn": "6.0.6", @@ -73,9 +71,10 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "keywords": [ "coding-agent", @@ -89,7 +88,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/coding-agent" }, "engines": { diff --git a/packages/coding-agent/src/bun/restore-sandbox-env.ts b/packages/coding-agent/src/bun/restore-sandbox-env.ts index b175a494..445ddfd6 100644 --- a/packages/coding-agent/src/bun/restore-sandbox-env.ts +++ b/packages/coding-agent/src/bun/restore-sandbox-env.ts @@ -4,6 +4,10 @@ * Bun compiled binaries have an empty `process.env` when running inside * sandbox environments (e.g. nono on Linux/macOS). On Linux we can recover * the environment from `/proc/self/environ`. + * + * Keep this in sync with getBunSandboxEnvValue() in + * packages/ai/src/utils/provider-env.ts. The ai package duplicates the lookup + * for direct consumers that do not go through this coding-agent entrypoint. */ import { readFileSync } from "node:fs"; diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index e4cb27dd..5d3c4af8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -21,12 +21,15 @@ export interface Args { help?: boolean; version?: boolean; mode?: Mode; + name?: string; noSession?: boolean; session?: string; + sessionId?: string; fork?: string; sessionDir?: string; models?: string[]; tools?: string[]; + excludeTools?: string[]; noTools?: boolean; noBuiltinTools?: boolean; extensions?: string[]; @@ -43,6 +46,7 @@ export interface Args { listModels?: string | true; offline?: boolean; verbose?: boolean; + projectTrustOverride?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -91,10 +95,18 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--append-system-prompt" && i + 1 < args.length) { result.appendSystemPrompt = result.appendSystemPrompt ?? []; result.appendSystemPrompt.push(args[++i]); + } else if (arg === "--name" || arg === "-n") { + if (i + 1 < args.length) { + result.name = args[++i]; + } else { + result.diagnostics.push({ type: "error", message: "--name requires a value" }); + } } else if (arg === "--no-session") { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { result.session = args[++i]; + } else if (arg === "--session-id" && i + 1 < args.length) { + result.sessionId = args[++i]; } else if (arg === "--fork" && i + 1 < args.length) { result.fork = args[++i]; } else if (arg === "--session-dir" && i + 1 < args.length) { @@ -110,6 +122,11 @@ export function parseArgs(args: string[]): Args { .split(",") .map((s) => s.trim()) .filter((name) => name.length > 0); + } else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) { + result.excludeTools = args[++i] + .split(",") + .map((s) => s.trim()) + .filter((name) => name.length > 0); } else if (arg === "--thinking" && i + 1 < args.length) { const level = args[++i]; if (isValidThinkingLevel(level)) { @@ -160,6 +177,10 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--verbose") { result.verbose = true; + } else if (arg === "--approve" || arg === "-a") { + result.projectTrustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + result.projectTrustOverride = false; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -208,7 +229,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove - ${APP_NAME} update [source|self|pi] Update pi and installed extensions + ${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions) ${APP_NAME} list List installed extensions from settings ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list @@ -224,15 +245,19 @@ ${chalk.bold("Options:")} --continue, -c Continue previous session --resume, -r Select a session to resume --session Use specific session file or partial UUID + --session-id Use exact project session ID, creating it if missing --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) + --name, -n Set session display name --models Comma-separated model patterns for Ctrl+P cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching --no-tools, -nt Disable all tools by default (built-in and extension) --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled --tools, -t Comma-separated allowlist of tool names to enable Applies to built-in, extension, and custom tools + --exclude-tools, -xt Comma-separated denylist of tool names to disable + Applies to built-in, extension, and custom tools --thinking Set thinking level: off, minimal, low, medium, high, xhigh --extension, -e Load an extension file (can be used multiple times) --no-extensions, -ne Disable extension discovery (explicit -e paths still work) @@ -246,6 +271,8 @@ ${chalk.bold("Options:")} --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) + --approve, -a Trust project-local files for this run + --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number @@ -271,6 +298,9 @@ ${chalk.bold("Examples:")} # Continue previous session ${APP_NAME} --continue "What did we discuss?" + # Start a named session + ${APP_NAME} --name "Refactor auth module" + # Use different model ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code" @@ -295,6 +325,9 @@ ${chalk.bold("Examples:")} # Read-only mode (no file modifications possible) ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" + # Disable one tool while keeping the rest available + ${APP_NAME} --exclude-tools ask_question + # Export a session file to HTML ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl ${APP_NAME} --export session.jsonl output.html @@ -302,6 +335,7 @@ ${chalk.bold("Examples:")} ${chalk.bold("Environment Variables:")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) + ANT_LING_API_KEY - Ant Ling API key OPENAI_API_KEY - OpenAI GPT API key AZURE_OPENAI_API_KEY - Azure OpenAI API key AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com) @@ -309,6 +343,7 @@ ${chalk.bold("Environment Variables:")} AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1) AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated) DEEPSEEK_API_KEY - DeepSeek API key + NVIDIA_API_KEY - NVIDIA NIM API key GEMINI_API_KEY - Google Gemini API key GROQ_API_KEY - Groq API key CEREBRAS_API_KEY - Cerebras API key @@ -318,6 +353,7 @@ ${chalk.bold("Environment Variables:")} OPENROUTER_API_KEY - OpenRouter API key AI_GATEWAY_API_KEY - Vercel AI Gateway API key ZAI_API_KEY - ZAI API key + ZAI_CODING_CN_API_KEY - ZAI Coding Plan API key (China) MISTRAL_API_KEY - Mistral API key MINIMAX_API_KEY - MiniMax API key MOONSHOT_API_KEY - Moonshot AI API key diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index fe0e32eb..e1da052a 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process if (mimeType) { // Handle image file const content = await readFile(absolutePath); - const base64Content = content.toString("base64"); let attachment: ImageContent; let dimensionNote: string | undefined; if (autoResizeImages) { - const resized = await resizeImage({ type: "image", data: base64Content, mimeType }); + const resized = await resizeImage(content, mimeType); if (!resized) { text += `[Image omitted: could not be resized below the inline image size limit.]\n`; continue; @@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process attachment = { type: "image", mimeType, - data: base64Content, + data: content.toString("base64"), }; } diff --git a/packages/coding-agent/src/cli/project-trust.ts b/packages/coding-agent/src/cli/project-trust.ts new file mode 100644 index 00000000..b27871a9 --- /dev/null +++ b/packages/coding-agent/src/cli/project-trust.ts @@ -0,0 +1,62 @@ +import chalk from "chalk"; +import type { ProjectTrustContext } from "../core/extensions/types.ts"; +import type { AppMode } from "../core/project-trust.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { showStartupInput, showStartupSelector } from "./startup-ui.ts"; + +export function createProjectTrustContext(options: { + cwd: string; + mode: AppMode; + settingsManager: SettingsManager; + hasUI: boolean; +}): ProjectTrustContext { + return { + cwd: options.cwd, + mode: options.mode === "interactive" ? "tui" : options.mode, + hasUI: options.hasUI, + ui: { + select: async (title, selectOptions) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupSelector( + options.settingsManager, + title, + selectOptions.map((option) => ({ label: option, value: option })), + ); + }, + confirm: async (title, message) => { + if (!options.hasUI) { + return false; + } + if (options.mode !== "interactive") { + return false; + } + return ( + (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ])) ?? false + ); + }, + input: async (title, placeholder) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupInput(options.settingsManager, title, placeholder); + }, + notify: (message, type = "info") => { + if (options.mode !== "interactive") { + const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; + console.error(color(message)); + } + }, + }, + }; +} diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts new file mode 100644 index 00000000..93841304 --- /dev/null +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -0,0 +1,181 @@ +import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { existsSync } from "fs"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; +import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; +import { + FirstTimeSetupComponent, + type FirstTimeSetupResult, +} from "../modes/interactive/components/first-time-setup.ts"; +import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; + +const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; +const OFFICIAL_APP_NAME = "pi"; +const OFFICIAL_CONFIG_DIR_NAME = ".pi"; + +interface DistributionMetadata { + packageName: string; + appName: string; + configDirName: string; +} + +function isOfficialDistribution({ packageName, appName, configDirName }: DistributionMetadata): boolean { + return ( + packageName === OFFICIAL_PACKAGE_NAME && + appName === OFFICIAL_APP_NAME && + configDirName === OFFICIAL_CONFIG_DIR_NAME + ); +} + +function createStartupTui(settingsManager: SettingsManager): TUI { + initTheme(settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + return ui; +} + +async function clearStartupTui(ui: TUI): Promise { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +/** + * First-time setup runs when all of these hold: + * - this is the official Pi distribution (not a fork/rebrand) + * - experimental features are enabled (PI_EXPERIMENTAL=1) + * - the default agent directory is used (no custom agent dir override) + * - setup was not completed before (settings.json does not exist) + */ +export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if ( + !isOfficialDistribution({ + packageName: PACKAGE_NAME, + appName: APP_NAME, + configDirName: CONFIG_DIR_NAME, + }) + ) { + return false; + } + if (!areExperimentalFeaturesEnabled()) { + return false; + } + if (process.env[ENV_AGENT_DIR]) { + return false; + } + return !existsSync(settingsPath); +} + +export async function showStartupSelector( + settingsManager: SettingsManager, + title: string, + options: Array<{ label: string; value: T }>, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: T | undefined) => { + if (settled) { + return; + } + settled = true; + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const selector = new ExtensionSelectorComponent( + title, + options.map((option) => option.label), + (option) => void finish(options.find((entry) => entry.label === option)?.value), + () => void finish(undefined), + { tui: ui }, + ); + ui.addChild(selector); + ui.setFocus(selector); + ui.start(); + }); +} + +/** Show the first-time setup dialog and persist the result */ +export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: FirstTimeSetupResult | undefined) => { + if (settled) { + return; + } + settled = true; + if (result) { + settingsManager.setTheme(result.theme); + settingsManager.setEnableAnalytics(result.shareAnalytics); + await settingsManager.flush(); + } + await clearStartupTui(ui); + ui.stop(); + resolve(); + }; + + const showSetup = async () => { + ui.start(); + const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 }); + setTheme(detection.theme); + const component = new FirstTimeSetupComponent({ + detectedTheme: detection.theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.requestRender(); + }; + + void showSetup(); + }); +} + +export async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + input.dispose(); + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const input = new ExtensionInputComponent( + title, + placeholder, + (value) => void finish(value), + () => void finish(undefined), + { + tui: ui, + }, + ); + ui.addChild(input); + ui.setFocus(input); + ui.start(); + }); +} diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index dd091804..75b2efaf 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -109,13 +109,27 @@ function getSelfUpdateCommandForMethod( switch (method) { case "bun-binary": return undefined; - case "pnpm": + case "pnpm": { + const match = readCommandOutput("pnpm", ["root", "-g"]) + ? undefined + : /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + const binDirArgs = match + ? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`] + : []; return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]), + makeSelfUpdateCommandStep("pnpm", [ + "install", + "-g", + "--ignore-scripts", + "--config.minimumReleaseAge=0", + ...binDirArgs, + updatePackageName, + ]), updatePackageName === installedPackageName ? undefined - : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), + : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]), ); + } case "yarn": return makeSelfUpdateCommand( makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]), @@ -125,7 +139,13 @@ function getSelfUpdateCommandForMethod( ); case "bun": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]), + makeSelfUpdateCommandStep("bun", [ + "install", + "-g", + "--ignore-scripts", + "--minimum-release-age=0", + updatePackageName, + ]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), @@ -139,6 +159,7 @@ function getSelfUpdateCommandForMethod( "install", "-g", "--ignore-scripts", + "--min-release-age=0", updatePackageName, ]); const uninstallStep = @@ -192,7 +213,9 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC } case "pnpm": { const root = readCommandOutput("pnpm", ["root", "-g"]); - return root ? [root, dirname(root)] : []; + if (root) return [root, dirname(root)]; + const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + return match ? [match[1]] : []; } case "yarn": { const dir = readCommandOutput("yarn", ["global", "dir"]); @@ -439,7 +462,13 @@ interface PackageJson { }; } -const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; +let pkg: PackageJson = {}; +try { + pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; +} catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + if (err.code !== "ENOENT") throw e; +} const piConfigName: string | undefined = pkg.piConfig?.name; export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent"; diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index baab11b0..7f29275a 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path"; import { resolvePath } from "../utils/paths.ts"; import type { AgentSession } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; -import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts"; +import type { + ProjectTrustContext, + ReplacedSessionContext, + SessionShutdownEvent, + SessionStartEvent, +} from "./extensions/index.ts"; import { emitSessionShutdownEvent } from "./extensions/runner.ts"; import type { CreateAgentSessionResult } from "./sdk.ts"; import { assertSessionCwdExists } from "./session-cwd.ts"; @@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: { agentDir: string; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; + projectTrustContext?: ProjectTrustContext; }) => Promise; /** @@ -186,7 +192,11 @@ export class AgentSessionRuntime { async switchSession( sessionPath: string, - options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise }, + options?: { + cwdOverride?: string; + withSession?: (ctx: ReplacedSessionContext) => Promise; + projectTrustContextFactory?: (cwd: string) => ProjectTrustContext; + }, ): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); if (beforeResult.cancelled) { @@ -203,6 +213,7 @@ export class AgentSessionRuntime { agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), }), ); await this.finishSessionReplacement(options?.withSession); @@ -221,7 +232,9 @@ export class AgentSessionRuntime { const previousSessionFile = this.session.sessionFile; const sessionDir = this.session.sessionManager.getSessionDir(); - const sessionManager = SessionManager.create(this.cwd, sessionDir); + const sessionManager = this.session.sessionManager.isPersisted() + ? SessionManager.create(this.cwd, sessionDir) + : SessionManager.inMemory(this.cwd); if (options?.parentSession) { sessionManager.newSession({ parentSession: options.parentSession }); } diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index adf6f9e0..3a6035cb 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts"; import { AuthStorage } from "./auth-storage.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { ModelRegistry } from "./model-registry.ts"; -import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts"; +import { + DefaultResourceLoader, + type DefaultResourceLoaderOptions, + type ResourceLoader, + type ResourceLoaderReloadOptions, +} from "./resource-loader.ts"; import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts"; import type { SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions { modelRegistry?: ModelRegistry; extensionFlagValues?: Map; resourceLoaderOptions?: Omit; + resourceLoaderReloadOptions?: ResourceLoaderReloadOptions; } /** @@ -54,6 +60,7 @@ export interface CreateAgentSessionFromServicesOptions { thinkingLevel?: ThinkingLevel; scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; tools?: string[]; + excludeTools?: CreateAgentSessionOptions["excludeTools"]; noTools?: CreateAgentSessionOptions["noTools"]; customTools?: ToolDefinition[]; } @@ -141,7 +148,7 @@ export async function createAgentSessionServices( agentDir, settingsManager, }); - await resourceLoader.reload(); + await resourceLoader.reload(options.resourceLoaderReloadOptions); const diagnostics: AgentSessionRuntimeDiagnostic[] = []; const extensionsResult = resourceLoader.getExtensions(); @@ -192,6 +199,7 @@ export async function createAgentSessionFromServices( thinkingLevel: options.thinkingLevel, scopedModels: options.scopedModels, tools: options.tools, + excludeTools: options.excludeTools, noTools: options.noTools, customTools: options.customTools, sessionStartEvent: options.sessionStartEvent, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b52ab910..0dc48294 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -33,7 +33,7 @@ import { resetApiProviders, streamSimple, } from "@earendil-works/pi-ai"; -import { theme } from "../modes/interactive/theme/theme.ts"; +import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; @@ -45,6 +45,7 @@ import { collectEntriesForBranchSummary, compact, estimateContextTokens, + estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, @@ -56,6 +57,7 @@ import { type ContextUsage, type ExtensionCommandContextActions, type ExtensionErrorListener, + type ExtensionMode, ExtensionRunner, type ExtensionUIContext, type InputSource, @@ -170,6 +172,8 @@ export interface AgentSessionConfig { initialActiveToolNames?: string[]; /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ allowedToolNames?: string[]; + /** Optional denylist of tool names. When provided, these tool names are not exposed. */ + excludedToolNames?: string[]; /** * Override base tools (useful for custom runtimes). * @@ -185,6 +189,7 @@ export interface AgentSessionConfig { export interface ExtensionBindings { uiContext?: ExtensionUIContext; + mode?: ExtensionMode; commandContextActions?: ExtensionCommandContextActions; abortHandler?: () => void; shutdownHandler?: ShutdownHandler; @@ -238,6 +243,14 @@ interface ToolDefinitionEntry { sourceInfo: SourceInfo; } +function estimateMessagesTokens(messages: AgentMessage[]): number { + let tokens = 0; + for (const message of messages) { + tokens += estimateTokens(message); + } + return tokens; +} + // ============================================================================ // Constants // ============================================================================ @@ -294,9 +307,11 @@ export class AgentSession { private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; + private _excludedToolNames?: Set; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; + private _extensionMode: ExtensionMode = "print"; private _extensionCommandContextActions?: ExtensionCommandContextActions; private _extensionAbortHandler?: () => void; private _extensionShutdownHandler?: ShutdownHandler; @@ -328,6 +343,7 @@ export class AgentSession { this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; + this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; @@ -350,6 +366,7 @@ export class AgentSession { private async _getRequiredRequestAuth(model: Model): Promise<{ apiKey: string; headers?: Record; + env?: Record; }> { const result = await this._modelRegistry.getApiKeyAndHeaders(model); if (!result.ok) { @@ -359,7 +376,7 @@ export class AgentSession { throw new Error(result.error); } if (result.apiKey) { - return { apiKey: result.apiKey, headers: result.headers }; + return { apiKey: result.apiKey, headers: result.headers, env: result.env }; } const isOAuth = this._modelRegistry.isUsingOAuth(model); @@ -376,13 +393,14 @@ export class AgentSession { private async _getCompactionRequestAuth(model: Model): Promise<{ apiKey?: string; headers?: Record; + env?: Record; }> { if (this.agent.streamFn === streamSimple) { return this._getRequiredRequestAuth(model); } const result = await this._modelRegistry.getApiKeyAndHeaders(model); - return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {}; + return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {}; } /** @@ -708,6 +726,16 @@ export class AgentSession { * Call this when completely done with the session. */ dispose(): void { + try { + this.abortRetry(); + this.abortCompaction(); + this.abortBranchSummary(); + this.abortBash(); + this.agent.abort(); + } catch { + // Dispose must succeed even if an abort hook throws. + } + this._extensionRunner.invalidate( "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", ); @@ -759,13 +787,14 @@ export class AgentSession { } /** - * Get all configured tools with name, description, parameter schema, and source metadata. + * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[] { return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, description: definition.description, parameters: definition.parameters, + promptGuidelines: definition.promptGuidelines, sourceInfo, })); } @@ -955,7 +984,13 @@ export class AgentSession { this._retryAttempt = 0; } - return await this._checkCompaction(msg); + if (await this._checkCompaction(msg)) { + return true; + } + + // The agent loop drains both queues before emitting agent_end. Any messages + // here were queued by agent_end extension handlers and need a continuation. + return this.agent.hasQueuedMessages(); } /** @@ -992,6 +1027,7 @@ export class AgentSession { currentText, currentImages, options?.source ?? "interactive", + this.isStreaming ? options?.streamingBehavior : undefined, ); if (inputResult.action === "handled") { preflightResult?.(true); @@ -1589,6 +1625,11 @@ export class AgentSession { // Queue Mode Management // ========================================================================= + private syncQueueModesFromSettings(): void { + this.agent.steeringMode = this.settingsManager.getSteeringMode(); + this.agent.followUpMode = this.settingsManager.getFollowUpMode(); + } + /** * Set steering message mode. * Saves to settings. @@ -1627,7 +1668,7 @@ export class AgentSession { throw new Error(formatNoModelSelectedMessage()); } - const { apiKey, headers } = await this._getCompactionRequestAuth(this.model); + const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model); const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); @@ -1686,6 +1727,7 @@ export class AgentSession { this._compactionAbortController.signal, this.thinkingLevel, this.agent.streamFn, + env, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -1701,6 +1743,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as @@ -1715,10 +1758,11 @@ export class AgentSession { }); } - const compactionResult = { + const compactionResult: CompactionResult = { summary, firstKeptEntryId, tokensBefore, + estimatedTokensAfter, details, }; this._emit({ @@ -1799,8 +1843,17 @@ export class AgentSession { return false; } - // Case 1: Overflow - LLM returned context overflow error + // Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded + // the configured window. A successful response over the configured window should compact + // but must not retry: the assistant answer already completed and agent.continue() cannot + // continue from an assistant message. if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { + const willRetry = assistantMessage.stopReason !== "stop"; + + if (!willRetry) { + return await this._runAutoCompaction("overflow", false); + } + if (this._overflowRecoveryAttempted) { this._emit({ type: "compaction_end", @@ -1821,7 +1874,7 @@ export class AgentSession { if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); } - return await this._runAutoCompaction("overflow", true); + return await this._runAutoCompaction("overflow", willRetry); } // Case 2: Threshold - context is getting large @@ -1858,56 +1911,39 @@ export class AgentSession { */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); - - this._emit({ type: "compaction_start", reason }); - this._autoCompactionAbortController = new AbortController(); + let started = false; try { if (!this.model) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } let apiKey: string | undefined; let headers: Record | undefined; + let env: Record | undefined; if (this.agent.streamFn === streamSimple) { const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); if (!authResult.ok || !authResult.apiKey) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } apiKey = authResult.apiKey; headers = authResult.headers; + env = authResult.env; } else { - ({ apiKey, headers } = await this._getCompactionRequestAuth(this.model)); + ({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model)); } const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } + this._emit({ type: "compaction_start", reason }); + this._autoCompactionAbortController = new AbortController(); + started = true; + let extensionCompaction: CompactionResult | undefined; let fromExtension = false; @@ -1959,6 +1995,7 @@ export class AgentSession { this._autoCompactionAbortController.signal, this.thinkingLevel, this.agent.streamFn, + env, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -1981,6 +2018,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as @@ -1999,6 +2037,7 @@ export class AgentSession { summary, firstKeptEntryId, tokensBefore, + estimatedTokensAfter, details, }; this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); @@ -2017,17 +2056,19 @@ export class AgentSession { return this.agent.hasQueuedMessages(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, - }); + if (started) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }); + } return false; } finally { this._autoCompactionAbortController = undefined; @@ -2050,6 +2091,9 @@ export class AgentSession { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; } + if (bindings.mode !== undefined) { + this._extensionMode = bindings.mode; + } if (bindings.commandContextActions !== undefined) { this._extensionCommandContextActions = bindings.commandContextActions; } @@ -2122,7 +2166,7 @@ export class AgentSession { } private _applyExtensionBindings(runner: ExtensionRunner): void { - runner.setUIContext(this._extensionUIContext); + runner.setUIContext(this._extensionUIContext, this._extensionMode); runner.bindCommandContext(this._extensionCommandContextActions); this._extensionErrorUnsubscriber?.(); @@ -2219,6 +2263,7 @@ export class AgentSession { { getModel: () => this.model, isIdle: () => !this.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), getSignal: () => this.agent.signal, abort: () => { if (this._extensionAbortHandler) { @@ -2244,6 +2289,7 @@ export class AgentSession { })(); }, getSystemPrompt: () => this.systemPrompt, + getSystemPromptOptions: () => this._baseSystemPromptOptions, }, { registerProvider: (name, config) => { @@ -2262,7 +2308,9 @@ export class AgentSession { const previousRegistryNames = new Set(this._toolRegistry.keys()); const previousActiveToolNames = this.getActiveToolNames(); const allowedToolNames = this._allowedToolNames; - const isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name); + const excludedToolNames = this._excludedToolNames; + const isAllowedTool = (name: string): boolean => + (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); const registeredTools = this._extensionRunner.getAllRegisteredTools(); const allCustomTools = [ @@ -2407,6 +2455,7 @@ export class AgentSession { const previousFlagValues = this._extensionRunner.getFlagValues(); await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" }); await this.settingsManager.reload(); + this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); this._buildRuntime({ @@ -2430,6 +2479,12 @@ export class AgentSession { // Auto-Retry // ========================================================================= + private _isNonRetryableProviderLimitError(errorMessage: string): boolean { + return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test( + errorMessage, + ); + } + /** * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). @@ -2442,6 +2497,7 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; + if (this._isNonRetryableProviderLimitError(err)) return false; // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( err, @@ -2747,16 +2803,18 @@ export class AgentSession { let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; - const { apiKey, headers } = await this._getRequiredRequestAuth(model); + const { apiKey, headers, env } = await this._getRequiredRequestAuth(model); const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, headers, + env, signal: this._branchSummaryAbortController.signal, customInstructions, replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, + streamFn: this.agent.streamFn, }); if (result.aborted) { return { cancelled: true, aborted: true }; @@ -2979,7 +3037,8 @@ export class AgentSession { * @returns Path to exported file */ async exportToHtml(outputPath?: string): Promise { - const themeName = this.settingsManager.getTheme(); + const configuredThemeName = this.settingsManager.getTheme(); + const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 7630fc80..3394a063 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -24,6 +24,7 @@ import { resolveConfigValue } from "./resolve-config-value.ts"; export type ApiKeyCredential = { type: "api_key"; key: string; + env?: Record; }; export type OAuthCredential = { @@ -45,6 +46,8 @@ type LockResult = { next?: string; }; +const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; + export interface AuthStorageBackend { withLock(fn: (current: string | undefined) => LockResult): T; withLockAsync(fn: (current: string | undefined) => Promise>): Promise; @@ -66,7 +69,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private ensureFileExists(): void { if (!existsSync(this.authPath)) { - writeFileSync(this.authPath, "{}", "utf-8"); + writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } } @@ -108,7 +111,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = fn(current); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } return result; @@ -153,7 +156,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const { result, next } = await fn(current); throwIfCompromised(); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } throwIfCompromised(); @@ -301,6 +304,14 @@ export class AuthStorage { return this.data[provider] ?? undefined; } + /** + * Get provider-scoped environment values for an API key credential. + */ + getProviderEnv(provider: string): Record | undefined { + const cred = this.data[provider]; + return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined; + } + /** * Set credential for a provider. */ @@ -469,7 +480,7 @@ export class AuthStorage { const cred = this.data[providerId]; if (cred?.type === "api_key") { - return resolveConfigValue(cred.key); + return resolveConfigValue(cred.key, cred.env); } if (cred?.type === "oauth") { diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 0039bc79..3378eb19 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -5,8 +5,8 @@ * a summary of the branch being left so context isn't lost. */ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; +import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, @@ -69,6 +69,8 @@ export interface GenerateBranchSummaryOptions { apiKey: string; /** Request headers for the model */ headers?: Record; + /** Provider-scoped environment values for the model */ + env?: Record; /** Abort signal for cancellation */ signal: AbortSignal; /** Optional custom instructions for summarization */ @@ -77,6 +79,8 @@ export interface GenerateBranchSummaryOptions { replaceInstructions?: boolean; /** Tokens reserved for prompt + LLM response (default 16384) */ reserveTokens?: number; + /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ + streamFn?: StreamFn; } // ============================================================================ @@ -284,7 +288,17 @@ export async function generateBranchSummary( entries: SessionEntry[], options: GenerateBranchSummaryOptions, ): Promise { - const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; + const { + model, + apiKey, + headers, + env, + signal, + customInstructions, + replaceInstructions, + reserveTokens = 16384, + streamFn, + } = options; // Token budget = context window minus reserved space for prompt + response const contextWindow = model.contextWindow || 128000; @@ -320,12 +334,14 @@ export async function generateBranchSummary( }, ]; - // Call LLM for summarization - const response = await completeSimple( - model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - { apiKey, headers, signal, maxTokens: 2048 }, - ); + // Call LLM for summarization. Prefer the session stream function so SDK + // request behavior (timeouts, retries, attribution headers) stays consistent + // without running through agent state/events. + const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; + const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; + const response = streamFn + ? await (await streamFn(model, context, requestOptions)).result() + : await completeSimple(model, context, requestOptions); // Check if aborted or errored if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index b8339fba..83315a84 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -104,6 +104,7 @@ export interface CompactionResult { summary: string; firstKeptEntryId: string; tokensBefore: number; + estimatedTokensAfter?: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; } @@ -225,6 +226,24 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett // Cut point detection // ============================================================================ +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). @@ -234,16 +253,9 @@ export function estimateTokens(message: AgentMessage): number { switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -261,18 +273,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; // Estimate images as 4000 chars, or 1200 tokens - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { @@ -528,10 +529,11 @@ function createSummarizationOptions( maxTokens: number, apiKey: string | undefined, headers: Record | undefined, + env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -566,6 +568,7 @@ export async function generateSummary( previousSummary?: string, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, + env?: Record, ): Promise { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -598,7 +601,7 @@ export async function generateSummary( }, ]; - const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel); + const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); const response = await completeSummarization( model, @@ -696,6 +699,10 @@ export function prepareCompaction( } } + if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { + return undefined; + } + // Extract file operations from messages and previous compaction const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); @@ -753,6 +760,7 @@ export async function compact( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, + env?: Record, ): Promise { const { firstKeptEntryId, @@ -783,6 +791,7 @@ export async function compact( previousSummary, thinkingLevel, streamFn, + env, ) : Promise.resolve("No prior history."), generateTurnPrefixSummary( @@ -791,6 +800,7 @@ export async function compact( settings.reserveTokens, apiKey, headers, + env, signal, thinkingLevel, streamFn, @@ -811,6 +821,7 @@ export async function compact( previousSummary, thinkingLevel, streamFn, + env, ); } @@ -839,6 +850,7 @@ async function generateTurnPrefixSummary( reserveTokens: number, apiKey: string | undefined, headers?: Record, + env?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, @@ -861,7 +873,7 @@ async function generateTurnPrefixSummary( const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), streamFn, ); diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts index 64218b2a..6cfc1622 100644 --- a/packages/coding-agent/src/core/compaction/utils.ts +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -165,6 +165,6 @@ export function serializeConversation(messages: Message[]): string { // Summarization System Prompt // ============================================================================ -export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; diff --git a/packages/coding-agent/src/core/experimental.ts b/packages/coding-agent/src/core/experimental.ts new file mode 100644 index 00000000..12d33c74 --- /dev/null +++ b/packages/coding-agent/src/core/experimental.ts @@ -0,0 +1,3 @@ +export function areExperimentalFeaturesEnabled(): boolean { + return process.env.PI_EXPERIMENTAL === "1"; +} diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 07ab3e25..cfdd1612 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -605,9 +605,24 @@ } function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function sanitizeMarkdownUrl(value) { + const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, ''); + if (!href) return href; + + const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/); + if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) { + return null; + } + + return href; } /** @@ -1566,10 +1581,11 @@ } }, renderer: { - // Sanitize link URLs to prevent javascript:/vbscript:/data: XSS + // Sanitize link URLs with a scheme allow-list. Browsers strip C0 + // controls from schemes, so strip them before checking and emitting. link(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return this.parser.parseInline(token.tokens); } let out = ''; return out; }, - // Sanitize image src URLs + // Sanitize image src URLs with the same scheme allow-list. image(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return escapeHtml(token.text || ''); } let out = '' + escapeHtml(token.text || '') + 'null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,f=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),d=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",d).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:f,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:d,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,$=/^( {2,}|\\)\n(?!\s*$)/,R=/[\p{P}\p{S}]/u,S=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,z=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,S).getRegex(),A=r(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,R).getRegex(),_=r("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),P=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),I=r(/\\(punct)/,"gu").replace(/punct/g,R).getRegex(),L=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),B=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),C=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",B).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),E=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",E).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Z=r(/^!?\[(label)\]\[(ref)\]/).replace("label",E).replace("ref",h).getRegex(),v=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),D={_backpedal:s,anyPunctuation:I,autolink:L,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:$,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:A,emStrongRDelimAst:_,emStrongRDelimUnd:P,escape:y,link:q,nolink:v,punctuation:z,reflink:Z,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",Z).replace("nolink",v).getRegex(),tag:C,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},H=e=>G[e];function X(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,H)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,H);return e}function F(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function U(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:J(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=J(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:J(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=J(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=J(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),K(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return K(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class W{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new V,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:j.normal,inline:N.normal};this.options.pedantic?(n.block=j.pedantic,n.inline=N.pedantic):this.options.gfm&&(n.block=j.gfm,this.options.breaks?n.inline=N.breaks:n.inline=N.gfm),this.tokenizer.rules=n}static get rules(){return{block:j,inline:N}}static lex(e,t){return new W(t).lex(e)}static lexInline(e,t){return new W(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class Y{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:X(r,!0))+"
\n":"
"+(n?r:X(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+X(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${X(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=F(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=F(e);if(null===s)return X(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new Y(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new V(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ne;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ne.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return W.lex(e,t??this.defaults)}parser(e,t){return te.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?W.lex:W.lexInline,o=r.hooks?r.hooks.provideParser():e?te.parse:te.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+X(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const re=new se;function ie(e,t){return re.parse(e,t)}ie.options=ie.setOptions=function(e){return re.setOptions(e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.getDefaults=t,ie.defaults=e.defaults,ie.use=function(...e){return re.use(...e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.walkTokens=function(e,t){return re.walkTokens(e,t)},ie.parseInline=re.parseInline,ie.Parser=te,ie.parser=te.parse,ie.Renderer=Y,ie.TextRenderer=ee,ie.Lexer=W,ie.lexer=W.lex,ie.Tokenizer=V,ie.Hooks=ne,ie.parse=ie;const le=ie.options,oe=ie.setOptions,ae=ie.use,ce=ie.walkTokens,he=ie.parseInline,pe=ie,ue=te.parse,ge=W.lex;e.Hooks=ne,e.Lexer=W,e.Marked=se,e.Parser=te,e.Renderer=Y,e.TextRenderer=ee,e.Tokenizer=V,e.getDefaults=t,e.lexer=ge,e.marked=ie,e.options=le,e.parse=pe,e.parseInline=he,e.parser=ue,e.setOptions=oe,e.use=ae,e.walkTokens=ce})); + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var N=Object.defineProperty;var Oe=Object.getOwnPropertyDescriptor;var we=Object.getOwnPropertyNames;var ye=Object.prototype.hasOwnProperty;var Pe=(l,e)=>{for(var t in e)N(l,t,{get:e[t],enumerable:!0})},Se=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of we(e))!ye.call(l,s)&&s!==t&&N(l,s,{get:()=>e[s],enumerable:!(n=Oe(e,s))||n.enumerable});return l};var $e=l=>Se(N({},"__esModule",{value:!0}),l);var Rt={};Pe(Rt,{Hooks:()=>P,Lexer:()=>x,Marked:()=>C,Parser:()=>b,Renderer:()=>y,TextRenderer:()=>S,Tokenizer:()=>w,defaults:()=>T,getDefaults:()=>_,lexer:()=>bt,marked:()=>g,options:()=>ht,parse:()=>mt,parseInline:()=>ft,parser:()=>xt,setOptions:()=>kt,use:()=>dt,walkTokens:()=>gt});module.exports=$e(Rt);function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var T=_();function Q(l){T=l}var z={exec:()=>null};function E(l){let e=[];return t=>{let n=Math.max(0,Math.min(3,t-1)),s=e[n];return s||(s=l(n),e[n]=s),s}}function d(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(m.caret,"$1"),t=t.replace(s,i),n},getRegex:()=>new RegExp(t,e)};return n}var Le=((l="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:E(l=>new RegExp(`^ {0,${l}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)),hrRegex:E(l=>new RegExp(`^ {0,${l}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`)),fencesBeginRegex:E(l=>new RegExp(`^ {0,${l}}(?:\`\`\`|~~~)`)),headingBeginRegex:E(l=>new RegExp(`^ {0,${l}}#`)),htmlBeginRegex:E(l=>new RegExp(`^ {0,${l}}<(?:[a-z].*>|!--)`,"i")),blockquoteBeginRegex:E(l=>new RegExp(`^ {0,${l}}>`))},_e=/^(?:[ \t]*(?:\n|$))+/,ze=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Me=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,D=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Ee=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,ae=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,le=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Ie=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),U=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Ae=/^[^\n]+/,K=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ce=d(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",K).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Be=d(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),H="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",W=/|$))/,De=d("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",W).replace("tag",H).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ue=d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),qe=d(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",ue).getRegex(),X={blockquote:qe,code:ze,def:Ce,fences:Me,heading:Ee,hr:D,html:De,lheading:le,list:Be,newline:_e,paragraph:ue,table:z,text:Ae},ie=d("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),ve={...X,lheading:Ie,table:ie,paragraph:d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ie).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex()},He={...X,html:d(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",W).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:z,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:d(U).replace("hr",D).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",le).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Ze=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Ge=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,pe=/^( {2,}|\\)\n(?!\s*$)/,Ne=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Le?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),he=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,Ke=d(he,"u").replace(/punct/g,I).getRegex(),We=d(he,"u").replace(/punct/g,ce).getRegex(),ke="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Xe=d(ke,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Je=d(ke,"gu").replace(/notPunctSpace/g,Fe).replace(/punctSpace/g,je).replace(/punct/g,ce).getRegex(),Ve=d("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Ye=d(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,I).getRegex(),et="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",tt=d(et,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),nt=d(/\\(punct)/,"gu").replace(/punct/g,I).getRegex(),rt=d(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),st=d(W).replace("(?:-->|$)","-->").getRegex(),it=d("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",st).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),v=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,ot=d(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",v).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),de=d(/^!?\[(label)\]\[(ref)\]/).replace("label",v).replace("ref",K).getRegex(),ge=d(/^!?\[(ref)\](?:\[\])?/).replace("ref",K).getRegex(),at=d("reflink|nolink(?!\\()","g").replace("reflink",de).replace("nolink",ge).getRegex(),oe=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,V={_backpedal:z,anyPunctuation:nt,autolink:rt,blockSkip:Ue,br:pe,code:Ge,del:z,delLDelim:z,delRDelim:z,emStrongLDelim:Ke,emStrongRDelimAst:Xe,emStrongRDelimUnd:Ve,escape:Ze,link:ot,nolink:ge,punctuation:Qe,reflink:de,reflinkSearch:at,tag:it,text:Ne,url:z},lt={...V,link:d(/^!?\[(label)\]\((.*?)\)/).replace("label",v).getRegex(),reflink:d(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",v).getRegex()},j={...V,emStrongRDelimAst:Je,emStrongLDelim:We,delLDelim:Ye,delRDelim:tt,url:d(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",oe).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:d(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>pt[l];function O(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function Y(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function ee(l,e){let t=l.replace(m.findPipe,(r,i,o)=>{let u=!1,a=i;for(;--a>=0&&o[a]==="\\";)u=!u;return u?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length=0&&m.blankLine.test(e[t]);)t--;return e.length-t<=2?l:e.slice(0,t+1).join(` +`)}function me(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n0?-2:-1}function xe(l,e=0){let t=e,n="";for(let s of l)if(s===" "){let r=4-t%4;n+=" ".repeat(r),t+=r}else n+=s,t++;return n}function be(l,e,t,n,s){let r=e.href,i=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:r,title:i,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,u}function ct(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(r=>{let i=r.match(t.other.beginningSpace);if(i===null)return r;let[o]=i;return o.length>=s.length?r.slice(s.length):r}).join(` +`)}var w=class{options;rules;lexer;constructor(e){this.options=e||T}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=this.options.pedantic?t[0]:te(t[0]),s=n.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n,codeBlockStyle:"indented",text:s}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=ct(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=L(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:L(t[0],` +`),depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:L(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=L(t[0],` +`).split(` +`),s="",r="",i=[];for(;n.length>0;){let o=!1,u=[],a;for(a=0;a1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let i=this.rules.other.listItemRegex(n),o=!1;for(;e;){let a=!1,c="",p="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let k=xe(t[2].split(` +`,1)[0],t[1].length),h=e.split(` +`,1)[0],R=!k.trim(),f=0;if(this.options.pedantic?(f=2,p=k.trimStart()):R?f=t[1].length+1:(f=k.search(this.rules.other.nonSpaceChar),f=f>4?1:f,p=k.slice(f),f+=t[1].length),R&&this.rules.other.blankLine.test(h)&&(c+=h+` +`,e=e.substring(h.length+1),a=!0),!a){let $=this.rules.other.nextBulletRegex(f),ne=this.rules.other.hrRegex(f),re=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),Re=this.rules.other.htmlBeginRegex(f),Te=this.rules.other.blockquoteBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],B;if(h=G,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),B=h):B=h.replace(this.rules.other.tabCharGlobal," "),re.test(h)||se.test(h)||Re.test(h)||Te.test(h)||$.test(h)||ne.test(h))break;if(B.search(this.rules.other.nonSpaceChar)>=f||!h.trim())p+=` +`+B.slice(f);else{if(R||k.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||re.test(k)||se.test(k)||ne.test(k))break;p+=` +`+h}R=!h.trim(),c+=G+` +`,e=e.substring(G.length+1),k=B.slice(f)}}r.loose||(o?r.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(o=!0)),r.items.push({type:"list_item",raw:c,task:!!this.options.gfm&&this.rules.other.listIsTask.test(p),loose:!1,text:p,tokens:[]}),r.raw+=c}let u=r.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a of r.items){this.lexer.state.top=!1,a.tokens=this.lexer.blockTokens(a.text,[]);let c=a.tokens[0];if(a.task&&(c?.type==="text"||c?.type==="paragraph")){a.text=a.text.replace(this.rules.other.listReplaceTask,""),c.raw=c.raw.replace(this.rules.other.listReplaceTask,""),c.text=c.text.replace(this.rules.other.listReplaceTask,"");for(let k=this.lexer.inlineQueue.length-1;k>=0;k--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[k].src)){this.lexer.inlineQueue[k].src=this.lexer.inlineQueue[k].src.replace(this.rules.other.listReplaceTask,"");break}let p=this.rules.other.listTaskCheckbox.exec(a.raw);if(p){let k={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};a.checked=k.checked,r.loose?a.tokens[0]&&["paragraph","text"].includes(a.tokens[0].type)&&"tokens"in a.tokens[0]&&a.tokens[0].tokens?(a.tokens[0].raw=k.raw+a.tokens[0].raw,a.tokens[0].text=k.raw+a.tokens[0].text,a.tokens[0].tokens.unshift(k)):a.tokens.unshift({type:"paragraph",raw:k.raw,text:k.raw,tokens:[k]}):a.tokens.unshift(k)}}else a.task&&(a.task=!1);if(!r.loose){let p=a.tokens.filter(h=>h.type==="space"),k=p.length>0&&p.some(h=>this.rules.other.anyLine.test(h.raw));r.loose=k}}if(r.loose)for(let a of r.items){a.loose=!0;for(let c of a.tokens)c.type==="text"&&(c.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t){let n=te(t[0]);return{type:"html",block:!0,raw:n,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:n}}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:L(t[0],` +`),href:s,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=ee(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:L(t[0],` +`),header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?i.align.push("right"):this.rules.other.tableAlignCenter.test(o)?i.align.push("center"):this.rules.other.tableAlignLeft.test(o)?i.align.push("left"):i.align.push(null);for(let o=0;o({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[a]})));return i}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:L(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let i=L(n.slice(0,-1),"\\");if((n.length-i.length)%2===0)return}else{let i=me(t[2],"()");if(i===-2)return;if(i>-1){let u=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,u).trim(),t[3]=""}}let s=t[2],r="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),be(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[s.toLowerCase()];if(!r){let i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return be(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||!s[1]&&!s[2]&&!s[3]&&!s[4]||s[4]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[3]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=0,p=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(p.lastIndex=0,t=t.slice(-1*e.length+i);(s=p.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(u=[...o].length,s[3]||s[4]){a+=u;continue}else if((s[5]||s[6])&&i%3&&!((i+u)%3)){c+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+c);let k=[...s[0]][0].length,h=e.slice(0,i+s.index+k+u);if(Math.min(i,u)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let R=h.slice(2,-2);return{type:"strong",raw:h,text:R,tokens:this.lexer.inlineTokens(R)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let s=this.rules.inline.delLDelim.exec(e);if(!s)return;if(!(s[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=this.rules.inline.delRDelim;for(c.lastIndex=0,t=t.slice(-1*e.length+i);(s=c.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o||(u=[...o].length,u!==i))continue;if(s[3]||s[4]){a+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a);let p=[...s[0]][0].length,k=e.slice(0,i+s.index+p+u),h=k.slice(i,-i);return{type:"del",raw:k,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||T,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:q.normal,inline:A.normal};this.options.pedantic?(t.block=q.pedantic,t.inline=A.pedantic):this.options.gfm&&(t.block=q.gfm,this.options.breaks?t.inline=A.breaks:t.inline=A.gfm),this.tokenizer.rules=t}static get rules(){return{block:q,inline:A}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(r=o.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let o=t.at(-1);r.raw.length===1&&o!==void 0?o.raw+=` +`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.raw,this.inlineQueue.at(-1).src=o.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let o=1/0,u=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},u),typeof a=="number"&&a>=0&&(o=Math.min(o,a))}),o<1/0&&o>=0&&(i=e.substring(0,o+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let o=t.at(-1);n&&o?.type==="paragraph"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){this.tokenizer.lexer=this;let n=e,s=null;if(this.tokens.links){let a=Object.keys(this.tokens.links);if(a.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!==null;)a.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!==null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!==null;)r=s[2]?s[2].length:0,n=n.slice(0,s.index+r)+"["+"a".repeat(s[0].length-r-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let i=!1,o="",u=1/0;for(;e;){if(e.length(a=p.call({lexer:this},e,t))?(e=e.substring(a.raw.length),t.push(a),!0):!1))continue;if(a=this.tokenizer.escape(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.tag(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.link(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(a.raw.length);let p=t.at(-1);a.type==="text"&&p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(a=this.tokenizer.emStrong(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.codespan(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.br(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.del(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.autolink(e)){e=e.substring(a.raw.length),t.push(a);continue}if(!this.state.inLink&&(a=this.tokenizer.url(e))){e=e.substring(a.raw.length),t.push(a);continue}let c=e;if(this.options.extensions?.startInline){let p=1/0,k=e.slice(1),h;this.options.extensions.startInline.forEach(R=>{h=R.call({lexer:this},k),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(c=e.substring(0,p+1))}if(a=this.tokenizer.inlineText(c)){e=e.substring(a.raw.length),a.raw.slice(-1)!=="_"&&(o=a.raw.slice(-1)),i=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return t}infiniteLoopError(e){let t="Infinite loop on byte: "+e;if(this.options.silent)console.error(t);else throw new Error(t)}};var y=class{options;parser;constructor(e){this.options=e||T}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],r=e.replace(m.endingNewline,"")+` +`;return s?'
    '+(n?r:O(r,!0))+`
    +`:"
    "+(n?r:O(r,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${O(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),r=Y(e);if(r===null)return s;e=r;let i='
    ",i}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let r=Y(e);if(r===null)return O(n);e=r;let i=`${O(n)}{let o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let u=r.renderer.apply(this,o);return u===!1&&(u=i.apply(this,o)),u}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){let r=this.defaults.renderer||new y(this.defaults);for(let i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let o=i,u=n.renderer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p||""}}s.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new w(this.defaults);for(let i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let o=i,u=n.tokenizer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new P;for(let i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let o=i,u=n.hooks[o],a=r[o];P.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async&&P.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await u.call(r,c);return a.call(r,k)})();let p=u.call(r,c);return a.call(r,p)}:r[o]=(...c)=>{if(this.defaults.async)return(async()=>{let k=await u.apply(r,c);return k===!1&&(k=await a.apply(r,c)),k})();let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,i=n.walkTokens;s.walkTokens=function(o){let u=[];return u.push(i.call(this,o)),r&&(u=u.concat(r.call(this,o))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let r={...s},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=e),i.async)return(async()=>{let u=i.hooks?await i.hooks.preprocess(n):n,c=await(i.hooks?await i.hooks.provideLexer(e):e?x.lex:x.lexInline)(u,i),p=i.hooks?await i.hooks.processAllTokens(c):c;i.walkTokens&&await Promise.all(this.walkTokens(p,i.walkTokens));let h=await(i.hooks?await i.hooks.provideParser(e):e?b.parse:b.parseInline)(p,i);return i.hooks?await i.hooks.postprocess(h):h})().catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let a=(i.hooks?i.hooks.provideLexer(e):e?x.lex:x.lexInline)(n,i);i.hooks&&(a=i.hooks.processAllTokens(a)),i.walkTokens&&this.walkTokens(a,i.walkTokens);let p=(i.hooks?i.hooks.provideParser(e):e?b.parse:b.parseInline)(a,i);return i.hooks&&(p=i.hooks.postprocess(p)),p}catch(u){return o(u)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+O(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new C;function g(l,e){return M.parse(l,e)}g.options=g.setOptions=function(l){return M.setOptions(l),g.defaults=M.defaults,Q(g.defaults),g};g.getDefaults=_;g.defaults=T;g.use=function(...l){return M.use(...l),g.defaults=M.defaults,Q(g.defaults),g};g.walkTokens=function(l,e){return M.walkTokens(l,e)};g.parseInline=M.parseInline;g.Parser=b;g.parser=b.parse;g.Renderer=y;g.TextRenderer=S;g.Lexer=x;g.lexer=x.lex;g.Tokenizer=w;g.Hooks=P;g.parse=g;var ht=g.options,kt=g.setOptions,dt=g.use,gt=g.walkTokens,ft=g.parseInline,mt=g,xt=b.parse,bt=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index acbc50b1..3ed7a662 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -66,6 +66,7 @@ export type { ExtensionFactory, ExtensionFlag, ExtensionHandler, + ExtensionMode, // Runtime ExtensionRuntime, ExtensionShortcut, @@ -97,6 +98,11 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventDecision, + ProjectTrustEventResult, + ProjectTrustHandler, // Provider Registration ProviderConfig, ProviderModelConfig, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d6823a59..a87c9635 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -117,6 +117,30 @@ function getAliases(): Record { type HandlerFn = (...args: unknown[]) => Promise; +let extensionCacheCwd: string | undefined; +let extensionCacheGeneration = 0; +const extensionCache = new Map(); + +interface ExtensionCacheToken { + cwd: string; + generation: number; +} + +export function clearExtensionCache(): void { + extensionCache.clear(); + extensionCacheCwd = undefined; + extensionCacheGeneration++; +} + +function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { + const resolvedCwd = resolvePath(cwd); + if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { + clearExtensionCache(); + } + extensionCacheCwd = resolvedCwd; + return { cwd: resolvedCwd, generation: extensionCacheGeneration }; +} + /** * Create a runtime with throwing stubs for action methods. * Runner.bindCore() replaces these with real implementations. @@ -328,7 +352,22 @@ function createExtensionAPI( return api; } -async function loadExtensionModule(extensionPath: string) { +function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { + return ( + cacheToken !== undefined && + extensionCacheCwd === cacheToken.cwd && + extensionCacheGeneration === cacheToken.generation + ); +} + +async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { + if (isCurrentCacheToken(cacheToken)) { + const cachedFactory = extensionCache.get(extensionPath); + if (cachedFactory) { + return cachedFactory; + } + } + const jiti = createJiti(import.meta.url, { moduleCache: false, // In Bun binary: use virtualModules for bundled packages (no filesystem resolution) @@ -339,7 +378,13 @@ async function loadExtensionModule(extensionPath: string) { const module = await jiti.import(extensionPath, { default: true }); const factory = module as ExtensionFactory; - return typeof factory !== "function" ? undefined : factory; + if (typeof factory !== "function") { + return undefined; + } + if (isCurrentCacheToken(cacheToken)) { + extensionCache.set(extensionPath, factory); + } + return factory; } /** @@ -370,11 +415,12 @@ async function loadExtension( cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, + cacheToken?: ExtensionCacheToken, ): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { - const factory = await loadExtensionModule(resolvedPath); + const factory = await loadExtensionModule(resolvedPath, cacheToken); if (!factory) { return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } @@ -410,15 +456,28 @@ export async function loadExtensionFromFactory( /** * Load extensions from paths. */ -export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { +async function loadExtensionsInternal( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, + useCache = false, +): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; - const resolvedCwd = resolvePath(cwd); + const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined; + const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); - const runtime = createExtensionRuntime(); + const resolvedRuntime = runtime ?? createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime); + const { extension, error } = await loadExtension( + extPath, + resolvedCwd, + resolvedEventBus, + resolvedRuntime, + cacheToken, + ); if (error) { errors.push({ path: extPath, error }); @@ -433,10 +492,28 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev return { extensions, errors, - runtime, + runtime: resolvedRuntime, }; } +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime); +} + +export async function loadExtensionsCached( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime, true); +} + interface PiManifest { extensions?: string[]; themes?: string[]; diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 167c52cc..9cb0e657 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -28,15 +28,20 @@ import type { ExtensionError, ExtensionEvent, ExtensionFlag, + ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, InputEvent, InputEventResult, InputSource, + LoadExtensionsResult, MessageEndEvent, MessageEndEventResult, MessageRenderer, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, ProviderConfig, RegisteredCommand, RegisteredTool, @@ -115,6 +120,7 @@ interface BeforeAgentStartCombinedResult { type RunnerEmitEvent = Exclude< ExtensionEvent, | ToolCallEvent + | ProjectTrustEvent | ToolResultEvent | UserBashEvent | ContextEvent @@ -188,6 +194,38 @@ export async function emitSessionShutdownEvent( return false; } +export async function emitProjectTrustEvent( + extensionsResult: LoadExtensionsResult, + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> { + const errors: ExtensionError[] = []; + for (const ext of extensionsResult.extensions) { + // A single extension may register multiple handlers for the same event. + // The first project_trust handler that returns yes/no wins; undecided falls through. + const handlers = ext.handlers.get("project_trust"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; + if (handlerResult.trusted === "undecided") { + continue; + } + return { result: handlerResult, errors }; + } catch (error) { + errors.push({ + extensionPath: ext.path, + event: event.type, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + } + return { errors }; +} + const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, @@ -225,12 +263,14 @@ export class ExtensionRunner { private extensions: Extension[]; private runtime: ExtensionRuntime; private uiContext: ExtensionUIContext; + private mode: ExtensionMode = "print"; private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; + private isProjectTrustedFn: () => boolean = () => true; private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => void = () => {}; @@ -238,6 +278,7 @@ export class ExtensionRunner { private getContextUsageFn: () => ContextUsage | undefined = () => undefined; private compactFn: (options?: CompactOptions) => void = () => {}; private getSystemPromptFn: () => string = () => ""; + private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () => ({ cwd: this.cwd }); private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private forkHandler: ForkHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); @@ -290,6 +331,7 @@ export class ExtensionRunner { // Context actions (required) this.getModel = contextActions.getModel; this.isIdleFn = contextActions.isIdle; + this.isProjectTrustedFn = contextActions.isProjectTrusted; this.getSignalFn = contextActions.getSignal; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; @@ -297,6 +339,7 @@ export class ExtensionRunner { this.getContextUsageFn = contextActions.getContextUsage; this.compactFn = contextActions.compact; this.getSystemPromptFn = contextActions.getSystemPrompt; + this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd })); // Flush provider registrations queued during extension loading for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) { @@ -354,8 +397,9 @@ export class ExtensionRunner { this.reloadHandler = async () => {}; } - setUIContext(uiContext?: ExtensionUIContext): void { + setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void { this.uiContext = uiContext ?? noOpUIContext; + this.mode = mode; } getUIContext(): ExtensionUIContext { @@ -578,6 +622,10 @@ export class ExtensionRunner { runner.assertActive(); return runner.uiContext; }, + get mode() { + runner.assertActive(); + return runner.mode; + }, get hasUI() { runner.assertActive(); return runner.hasUI(); @@ -602,6 +650,10 @@ export class ExtensionRunner { runner.assertActive(); return runner.isIdleFn(); }, + isProjectTrusted: () => { + runner.assertActive(); + return runner.isProjectTrustedFn(); + }, get signal() { runner.assertActive(); return runner.getSignalFn(); @@ -641,6 +693,10 @@ export class ExtensionRunner { {}, Object.getOwnPropertyDescriptors(this.createContext()), ) as ExtensionCommandContext; + context.getSystemPromptOptions = () => { + this.assertActive(); + return this.getSystemPromptOptionsFn(); + }; context.waitForIdle = () => { this.assertActive(); return this.waitForIdleFn(); @@ -1036,7 +1092,12 @@ export class ExtensionRunner { } /** Emit input event. Transforms chain, "handled" short-circuits. */ - async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise { + async emitInput( + text: string, + images: ImageContent[] | undefined, + source: InputSource, + streamingBehavior?: "steer" | "followUp", + ): Promise { const ctx = this.createContext(); let currentText = text; let currentImages = images; @@ -1044,7 +1105,13 @@ export class ExtensionRunner { for (const ext of this.extensions) { for (const handler of ext.handlers.get("input") ?? []) { try { - const event: InputEvent = { type: "input", text: currentText, images: currentImages, source }; + const event: InputEvent = { + type: "input", + text: currentText, + images: currentImages, + source, + streamingBehavior, + }; const result = (await handler(event, ctx)) as InputEventResult | undefined; if (result?.action === "handled") return result; if (result?.action === "transform") { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 58b85227..a869a55d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -295,10 +295,14 @@ export interface CompactOptions { /** * Context passed to extension event handlers. */ +export type ExtensionMode = "tui" | "rpc" | "json" | "print"; + export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; - /** Whether UI is available (false in print/RPC mode) */ + /** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */ + mode: ExtensionMode; + /** Whether dialog-capable UI is available (true in TUI and RPC modes) */ hasUI: boolean; /** Current working directory */ cwd: string; @@ -310,6 +314,8 @@ export interface ExtensionContext { model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; + /** Whether project-local trust is active for this context. */ + isProjectTrusted(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ @@ -331,6 +337,9 @@ export interface ExtensionContext { * Includes session control methods only safe in user-initiated commands. */ export interface ExtensionCommandContext extends ExtensionContext { + /** Get the current base system-prompt construction options. */ + getSystemPromptOptions(): BuildSystemPromptOptions; + /** Wait for the agent to finish streaming */ waitForIdle(): Promise; @@ -488,9 +497,33 @@ export function defineTool; +} + +export type ProjectTrustHandler = ( + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +) => Promise | ProjectTrustEventResult; + /** Fired after session_start to allow extensions to provide additional resource paths. */ export interface ResourcesDiscoverEvent { type: "resources_discover"; @@ -756,6 +789,8 @@ export interface InputEvent { images?: ImageContent[]; /** Where the input came from */ source: InputSource; + /** How the input will be delivered during streaming, or undefined when idle */ + streamingBehavior?: "steer" | "followUp"; } /** Result from input event handler */ @@ -948,6 +983,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo /** Union of all event types */ export type ExtensionEvent = + | ProjectTrustEvent | ResourcesDiscoverEvent | SessionEvent | ContextEvent @@ -1086,6 +1122,7 @@ export interface ExtensionAPI { // Event Subscription // ========================================================================= + on(event: "project_trust", handler: ProjectTrustHandler): void; on(event: "resources_discover", handler: ExtensionHandler): void; on(event: "session_start", handler: ExtensionHandler): void; on( @@ -1211,7 +1248,7 @@ export interface ExtensionAPI { /** Get the list of currently active tool names. */ getActiveTools(): string[]; - /** Get all configured tools with parameter schema and source metadata. */ + /** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[]; /** Set the active tools by name. */ @@ -1254,7 +1291,7 @@ export interface ExtensionAPI { * // Register a new provider with custom models * pi.registerProvider("my-proxy", { * baseUrl: "https://proxy.example.com", - * apiKey: "PROXY_API_KEY", + * apiKey: "$PROXY_API_KEY", * api: "anthropic-messages", * models: [ * { @@ -1320,7 +1357,7 @@ export interface ProviderConfig { name?: string; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; - /** API key or environment variable name. Required when defining models (unless oauth provided). */ + /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */ apiKey?: string; /** API type. Required at provider or model level when defining models. */ api?: Api; @@ -1422,8 +1459,8 @@ export type GetSessionNameHandler = () => string | undefined; export type GetActiveToolsHandler = () => string[]; -/** Tool info with name, description, parameter schema, and source metadata */ -export type ToolInfo = Pick & { +/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */ +export type ToolInfo = Pick & { sourceInfo: SourceInfo; }; @@ -1493,6 +1530,7 @@ export interface ExtensionActions { export interface ExtensionContextActions { getModel: () => Model | undefined; isIdle: () => boolean; + isProjectTrusted: () => boolean; getSignal: () => AbortSignal | undefined; abort: () => void; hasPendingMessages: () => boolean; @@ -1500,6 +1538,7 @@ export interface ExtensionContextActions { getContextUsage: () => ContextUsage | undefined; compact: (options?: CompactOptions) => void; getSystemPrompt: () => string; + getSystemPromptOptions?: () => BuildSystemPromptOptions; } /** diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index b7ea09b8..f15001c2 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -1,5 +1,5 @@ import { type ExecFileException, execFile, spawnSync } from "child_process"; -import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs"; +import { existsSync, type FSWatcher, readFileSync, type Stats, statSync, unwatchFile, watchFile } from "fs"; import { dirname, join, resolve } from "path"; import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts"; @@ -80,6 +80,18 @@ function resolveBranchWithGitAsync(repoDir: string): Promise { }); } +function isWslEnvironment(): boolean { + return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP); +} + +function isWindowsMountedRepoPath(repoDir: string): boolean { + return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir); +} + +function shouldPollGitHead(repoDir: string): boolean { + return isWslEnvironment() && isWindowsMountedRepoPath(repoDir); +} + /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. * Token stats, model info available via ctx.sessionManager and ctx.model. @@ -92,6 +104,8 @@ export class FooterDataProvider { private cachedBranch: string | null | undefined = undefined; private gitPaths: GitPaths | null | undefined = undefined; private headWatcher: FSWatcher | null = null; + private headWatchFilePath: string | null = null; + private headWatchFileListener: ((current: Stats, previous: Stats) => void) | null = null; private reftableWatcher: FSWatcher | null = null; private reftableTablesListWatcher: FSWatcher | null = null; private reftableTablesListPath: string | null = null; @@ -255,6 +269,11 @@ export class FooterDataProvider { private clearGitWatchers(): void { closeWatcher(this.headWatcher); this.headWatcher = null; + if (this.headWatchFilePath && this.headWatchFileListener) { + unwatchFile(this.headWatchFilePath, this.headWatchFileListener); + this.headWatchFilePath = null; + this.headWatchFileListener = null; + } closeWatcher(this.reftableWatcher); this.reftableWatcher = null; closeWatcher(this.reftableTablesListWatcher); @@ -289,6 +308,8 @@ export class FooterDataProvider { this.clearGitWatchers(); if (!this.gitPaths) return; + const pollGitHead = shouldPollGitHead(this.gitPaths.repoDir); + // Watch the directory containing HEAD, not HEAD itself. // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. // fs.watch on a file stops working after the inode changes. @@ -301,7 +322,20 @@ export class FooterDataProvider { }, () => this.handleGitWatcherError(), ); - if (!this.headWatcher) { + if (pollGitHead) { + this.headWatchFilePath = this.gitPaths.headPath; + this.headWatchFileListener = (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.size !== previous.size + ) { + this.scheduleRefresh(); + } + }; + watchFile(this.headWatchFilePath, { interval: 1000 }, this.headWatchFileListener); + } + if (!this.headWatcher && !pollGitHead) { return; } diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts index 12ce9fb0..0910f4d6 100644 --- a/packages/coding-agent/src/core/http-dispatcher.ts +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -10,6 +10,9 @@ export const HTTP_IDLE_TIMEOUT_CHOICES = [ { label: "disabled", timeoutMs: 0 }, ] as const; +const originalGlobalFetch = globalThis.fetch; +let installedGlobalFetch: typeof globalThis.fetch | undefined; + export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -36,6 +39,13 @@ export function formatHttpIdleTimeoutMs(timeoutMs: number): string { return `${timeoutMs / 1000} sec`; } +export function applyHttpProxySettings(httpProxy: string | undefined): void { + const proxy = httpProxy?.trim(); + if (!proxy) return; + process.env.HTTP_PROXY ??= proxy; + process.env.HTTPS_PROXY ??= proxy; +} + export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); if (normalizedTimeoutMs === undefined) { @@ -51,5 +61,13 @@ export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TI // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's // bundled fetch can otherwise consume compressed responses through npm undici's // dispatcher without decompressing them, causing response.json() failures. - undici.install?.(); + // If a caller replaced fetch after module load, preserve that deliberate override. + const shouldInstallGlobals = + installedGlobalFetch === undefined + ? globalThis.fetch === originalGlobalFetch + : globalThis.fetch === installedGlobalFetch; + if (shouldInstallGlobals) { + undici.install?.(); + installedGlobalFetch = globalThis.fetch; + } } diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 71c45e9c..b7654f42 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -28,6 +28,7 @@ export { export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; export type { CompactionResult } from "./compaction/index.ts"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; +export { areExperimentalFeaturesEnabled } from "./experimental.ts"; // Extensions system export { type AgentEndEvent, diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 6e3a1031..305dca92 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,11 +25,15 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.ts"; +import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { clearConfigValueCache, + getConfigValueEnvVarNames, + isCommandConfigValue, + isConfigValueConfigured, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, @@ -92,6 +96,13 @@ const ThinkingLevelMapSchema = Type.Object({ xhigh: Type.Optional(ThinkingLevelMapValueSchema), }); +const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]); +const ChatTemplateKwargVariableSchema = Type.Object({ + $var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]), + omitWhenOff: Type.Optional(Type.Boolean()), +}); +const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]); + const OpenAICompletionsCompatSchema = Type.Object({ supportsStore: Type.Optional(Type.Boolean()), supportsDeveloperRole: Type.Optional(Type.Boolean()), @@ -110,9 +121,13 @@ const OpenAICompletionsCompatSchema = Type.Object({ Type.Literal("deepseek"), Type.Literal("zai"), Type.Literal("qwen"), + Type.Literal("chat-template"), Type.Literal("qwen-chat-template"), + Type.Literal("string-thinking"), + Type.Literal("ant-ling"), ]), ), + chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)), cacheControlFormat: Type.Optional(Type.Literal("anthropic")), openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), @@ -121,6 +136,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ }); const OpenAIResponsesCompatSchema = Type.Object({ + supportsDeveloperRole: Type.Optional(Type.Boolean()), sendSessionIdHeader: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); @@ -128,6 +144,9 @@ const OpenAIResponsesCompatSchema = Type.Object({ const AnthropicMessagesCompatSchema = Type.Object({ supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + supportsCacheControlOnTools: Type.Optional(Type.Boolean()), + forceAdaptiveThinking: Type.Optional(Type.Boolean()), }); const ProviderCompatSchema = Type.Union([ @@ -215,13 +234,6 @@ function formatValidationPath(error: TLocalizedValidationError): string { return path || "root"; } -/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ -function stripJsonComments(input: string): string { - return input - .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) - .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); -} - /** Provider override config (baseUrl, compat) without request auth/headers */ interface ProviderOverride { baseUrl?: string; @@ -239,6 +251,7 @@ export type ResolvedRequestAuth = ok: true; apiKey?: string; headers?: Record; + env?: Record; } | { ok: false; @@ -287,6 +300,13 @@ function mergeCompat( }; } + if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) { + mergedCompletions.chatTemplateKwargs = { + ...baseCompletions?.chatTemplateKwargs, + ...overrideCompletions.chatTemplateKwargs, + }; + } + return merged as Model["compat"]; } @@ -638,9 +658,10 @@ export class ModelRegistry { * Get API key for a model. */ hasConfiguredAuth(model: Model): boolean { + const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey; return ( this.authStorage.hasAuth(model.provider) || - this.providerRequestConfigs.get(model.provider)?.apiKey !== undefined + (providerApiKey !== undefined && isConfigValueConfigured(providerApiKey)) ); } @@ -682,17 +703,27 @@ export class ModelRegistry { async getApiKeyAndHeaders(model: Model): Promise { try { const providerConfig = this.providerRequestConfigs.get(model.provider); + const providerEnv = this.authStorage.getProviderEnv(model.provider); const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false }); const apiKey = apiKeyFromAuthStorage ?? (providerConfig?.apiKey - ? resolveConfigValueOrThrow(providerConfig.apiKey, `API key for provider "${model.provider}"`) + ? resolveConfigValueOrThrow( + providerConfig.apiKey, + `API key for provider "${model.provider}"`, + providerEnv, + ) : undefined); - const providerHeaders = resolveHeadersOrThrow(providerConfig?.headers, `provider "${model.provider}"`); + const providerHeaders = resolveHeadersOrThrow( + providerConfig?.headers, + `provider "${model.provider}"`, + providerEnv, + ); const modelHeaders = resolveHeadersOrThrow( this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)), `model "${model.provider}/${model.id}"`, + providerEnv, ); let headers = @@ -711,6 +742,7 @@ export class ModelRegistry { ok: true, apiKey, headers: headers && Object.keys(headers).length > 0 ? headers : undefined, + env: providerEnv && Object.keys(providerEnv).length > 0 ? providerEnv : undefined, }; } catch (error) { return { @@ -735,12 +767,15 @@ export class ModelRegistry { return authStatus; } - if (providerApiKey.startsWith("!")) { + if (isCommandConfigValue(providerApiKey)) { return { configured: true, source: "models_json_command" }; } - if (process.env[providerApiKey]) { - return { configured: true, source: "environment", label: providerApiKey }; + const envVarNames = getConfigValueEnvVarNames(providerApiKey); + if (envVarNames.length > 0) { + return isConfigValueConfigured(providerApiKey) + ? { configured: true, source: "environment", label: envVarNames.join(", ") } + : { configured: false }; } return { configured: true, source: "models_json_key" }; @@ -772,7 +807,9 @@ export class ModelRegistry { } const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey; - return providerApiKey ? resolveConfigValueUncached(providerApiKey) : undefined; + return providerApiKey + ? resolveConfigValueUncached(providerApiKey, this.authStorage.getProviderEnv(provider)) + : undefined; } /** diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 764c06f5..0017df00 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,10 +13,12 @@ import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", - anthropic: "claude-opus-4-7", + "ant-ling": "Ring-2.6-1T", + anthropic: "claude-opus-4-8", openai: "gpt-5.4", "azure-openai-responses": "gpt-5.4", "openai-codex": "gpt-5.5", + nvidia: "nvidia/nemotron-3-super-120b-a12b", deepseek: "deepseek-v4-pro", google: "gemini-3.1-pro-preview", "google-vertex": "gemini-3.1-pro-preview", @@ -27,6 +29,7 @@ export const defaultModelPerProvider: Record = { groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", zai: "glm-5.1", + "zai-coding-cn": "glm-5.1", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", @@ -337,9 +340,10 @@ export interface ResolveCliModelResult { export function resolveCliModel(options: { cliProvider?: string; cliModel?: string; + cliThinking?: ThinkingLevel; modelRegistry: ModelRegistry; }): ResolveCliModelResult { - const { cliProvider, cliModel, modelRegistry } = options; + const { cliProvider, cliModel, cliThinking, modelRegistry } = options; if (!cliModel) { return { model: undefined, warning: undefined, error: undefined }; @@ -418,6 +422,27 @@ export function resolveCliModel(options: { }); if (model) { + // If provider inference matched an unauthenticated provider/model pair, prefer + // one exact raw model-id match that is authenticated. This keeps + // "provider/model" syntax preferred when usable, but handles models whose + // literal id starts with a known provider name (for example + // commandcode model id "xiaomi/mimo-v2.5-pro"). + if (inferredProvider) { + const rawExactMatches = availableModels.filter( + (m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model), + ); + if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) { + const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m)); + if (authenticatedRawMatches.length === 1) { + return { + model: authenticatedRawMatches[0], + thinkingLevel: undefined, + warning: undefined, + error: undefined, + }; + } + } + } return { model, thinkingLevel, warning, error: undefined }; } @@ -448,12 +473,31 @@ export function resolveCliModel(options: { } if (provider) { - const fallbackModel = buildFallbackModel(provider, pattern, availableModels); + // Parse thinking level suffix from the pattern before building the fallback model, + // but only when --thinking is not explicitly provided. + // e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high" + let fallbackPattern = pattern; + let fallbackThinking: ThinkingLevel | undefined; + if (!cliThinking) { + const lastColon = pattern.lastIndexOf(":"); + if (lastColon !== -1) { + const suffix = pattern.substring(lastColon + 1); + if (isValidThinkingLevel(suffix)) { + fallbackPattern = pattern.substring(0, lastColon); + fallbackThinking = suffix; + } + } + } + + const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels); if (fallbackModel) { + const requestedThinking = cliThinking ?? fallbackThinking; + const model = + requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel; const fallbackWarning = warning - ? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.` - : `Model "${pattern}" not found for provider "${provider}". Using custom model id.`; - return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined }; + ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`; + return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; } } diff --git a/packages/coding-agent/src/core/output-guard.ts b/packages/coding-agent/src/core/output-guard.ts index c0783760..8781a51d 100644 --- a/packages/coding-agent/src/core/output-guard.ts +++ b/packages/coding-agent/src/core/output-guard.ts @@ -6,6 +6,42 @@ interface StdoutTakeoverState { let stdoutTakeoverState: StdoutTakeoverState | undefined; +const RAW_STDOUT_RETRY_DELAY_MS = 10; + +let rawStdoutWriteTail: Promise = Promise.resolve(); + +function getRawStdoutWrite(): StdoutTakeoverState["rawStdoutWrite"] { + if (stdoutTakeoverState) { + return stdoutTakeoverState.rawStdoutWrite; + } + return process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"]; +} + +async function writeRawStdoutChunk(text: string): Promise { + while (true) { + try { + await new Promise((resolve, reject) => { + try { + getRawStdoutWrite()(text, (error) => { + if (error) reject(error); + else resolve(); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + return; + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + const code = (writeError as Error & { code?: unknown }).code; + if (code !== "ENOBUFS" && code !== "EAGAIN" && code !== "EWOULDBLOCK") { + throw writeError; + } + await new Promise((resolve) => setTimeout(resolve, RAW_STDOUT_RETRY_DELAY_MS)); + } + } +} + export function takeOverStdout(): void { if (stdoutTakeoverState) { return; @@ -47,28 +83,26 @@ export function isStdoutTakenOver(): boolean { } export function writeRawStdout(text: string): void { - if (stdoutTakeoverState) { - stdoutTakeoverState.rawStdoutWrite(text); + if (text.length === 0) { return; } - process.stdout.write(text); + rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text)); + void rawStdoutWriteTail.catch(() => { + process.exit(1); + }); +} + +export async function waitForRawStdoutBackpressure(): Promise { + while (true) { + const tail = rawStdoutWriteTail; + await tail; + if (tail === rawStdoutWriteTail) { + return; + } + } } export async function flushRawStdout(): Promise { - if (stdoutTakeoverState) { - await new Promise((resolve, reject) => { - stdoutTakeoverState?.rawStdoutWrite("", (err) => { - if (err) reject(err); - else resolve(); - }); - }); - return; - } - - await new Promise((resolve, reject) => { - process.stdout.write("", (err) => { - if (err) reject(err); - else resolve(); - }); - }); + await waitForRawStdoutBackpressure(); + await writeRawStdoutChunk(""); } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 994adb56..ebdf974b 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,7 +1,7 @@ import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; function getEnv(): NodeJS.ProcessEnv { if (process.platform !== "linux" || Object.keys(process.env).length > 0) { @@ -27,6 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; +import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; @@ -44,6 +45,14 @@ function isOfflineModeEnabled(): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } +function isExactNpmVersion(version: string | undefined): boolean { + return valid(version ?? "") !== null; +} + +function getNpmVersionRange(version: string | undefined): string | undefined { + return version ? (validRange(version) ?? undefined) : undefined; +} + export interface PathMetadata { source: string; scope: SourceScope; @@ -119,6 +128,8 @@ type NpmSource = { type: "npm"; spec: string; name: string; + version?: string; + range?: string; pinned: boolean; }; @@ -206,6 +217,13 @@ function getHomeDir(): string { return process.env.HOME || homedir(); } +export function getExtensionTempFolder(agentDir: string): string { + const tempFolder = join(agentDir, "tmp", "extensions"); + mkdirSync(tempFolder, { recursive: true, mode: 0o700 }); + chmodSync(tempFolder, 0o700); + return tempFolder; +} + function prefixIgnorePattern(line: string, prefix: string): string | null { const trimmed = line.trim(); if (!trimmed) return null; @@ -956,6 +974,7 @@ export class DefaultPackageManager implements PackageManager { async install(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("install", source, `Installing ${source}...`, async () => { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); @@ -984,6 +1003,7 @@ export class DefaultPackageManager implements PackageManager { async remove(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("remove", source, `Removing ${source}...`, async () => { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); @@ -1047,14 +1067,15 @@ export class DefaultPackageManager implements PackageManager { for (const entry of sources) { const parsed = this.parseSource(entry.source); - if (parsed.type === "local" || parsed.pinned) { - continue; - } + // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, + // so include them to reconcile an existing clone when the configured ref changes. if (parsed.type === "npm") { - npmCandidates.push({ ...entry, parsed }); - continue; + if (!parsed.pinned) { + npmCandidates.push({ ...entry, parsed }); + } + } else if (parsed.type === "git") { + gitCandidates.push({ ...entry, parsed }); } - gitCandidates.push({ ...entry, parsed }); } const npmCheckTasks = npmCandidates.map((entry) => async () => ({ @@ -1103,8 +1124,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1118,7 +1139,7 @@ export class DefaultPackageManager implements PackageManager { const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`; const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`; - const specs = sources.map((entry) => `${entry.parsed.name}@latest`); + const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`)); await this.withProgress("update", sourceLabel, message, async () => { await this.installNpmBatch(specs, scope); @@ -1231,8 +1252,7 @@ export class DefaultPackageManager implements PackageManager { if (parsed.type === "npm") { let installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = - !existsSync(installedPath) || - (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); + !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; @@ -1384,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager { type: "npm", spec, name, - pinned: Boolean(version), + version, + range: getNpmVersionRange(version), + pinned: isExactNpmVersion(version), }; } @@ -1401,18 +1423,12 @@ export class DefaultPackageManager implements PackageManager { return { type: "local", path: source }; } - private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise { + private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise { const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) { return false; } - - const { version: pinnedVersion } = this.parseNpmSpec(source.spec); - if (!pinnedVersion) { - return true; - } - - return installedVersion === pinnedVersion; + return source.range ? satisfies(installedVersion, source.range) : true; } private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise { @@ -1426,8 +1442,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { return false; } @@ -1445,16 +1461,25 @@ export class DefaultPackageManager implements PackageManager { } } - private async getLatestNpmVersion(packageName: string): Promise { + private async getLatestNpmVersion(packageSpec: string, range?: string): Promise { const npmCommand = this.getNpmCommand(); const stdout = await this.runCommandCapture( npmCommand.command, - [...npmCommand.args, "view", packageName, "version", "--json"], + [...npmCommand.args, "view", packageSpec, "version", "--json"], { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }, ); const raw = stdout.trim(); if (!raw) throw new Error("Empty response from npm view"); - return JSON.parse(raw); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed === "string") { + return parsed; + } + if (Array.isArray(parsed)) { + const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0); + const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0]; + if (latest) return latest; + } + throw new Error("Unexpected response from npm view"); } private async gitHasAvailableUpdate(installedPath: string): Promise { @@ -1665,6 +1690,12 @@ export class DefaultPackageManager implements PackageManager { return { name, version }; } + private assertProjectTrustedForScope(scope: SourceScope): void { + if (scope === "project" && !this.settingsManager.isProjectTrusted()) { + throw new Error("Project is not trusted; refusing to access project package storage"); + } + } + private getNpmCommand(): { command: string; args: string[] } { const configuredCommand = this.settingsManager.getNpmCommand(); if (!configuredCommand || configuredCommand.length === 0) { @@ -1705,13 +1736,25 @@ export class DefaultPackageManager implements PackageManager { private getNpmInstallArgs(specs: string[], installRoot: string): string[] { const packageManagerName = this.getPackageManagerName(); + // Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules. + // Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and + // equivalent bun/pnpm settings) so package managers do not install or solve host-provided + // @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates. if (packageManagerName === "bun") { - return ["install", ...specs, "--cwd", installRoot]; + return ["install", ...specs, "--cwd", installRoot, "--omit=peer"]; } if (packageManagerName === "pnpm") { - return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"]; + return [ + "install", + ...specs, + "--prefix", + installRoot, + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", + "--config.strict-dep-builds=false", + ]; } - return ["install", ...specs, "--prefix", installRoot]; + return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"]; } private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { @@ -1766,6 +1809,11 @@ export class DefaultPackageManager implements PackageManager { return; } + if (source.ref) { + await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); + return; + } + const target = await this.getLocalGitUpdateTarget(targetDir); await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); } @@ -1778,7 +1826,8 @@ export class DefaultPackageManager implements PackageManager { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], { + const commitRef = `${ref}^{commit}`; + const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); @@ -1786,7 +1835,7 @@ export class DefaultPackageManager implements PackageManager { return; } - await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir }); + await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir }); // Clean untracked files (extensions should be pristine) await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); @@ -1867,6 +1916,7 @@ export class DefaultPackageManager implements PackageManager { return this.getTemporaryDir("npm"); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm"); } return join(this.agentDir, "npm"); @@ -1907,6 +1957,7 @@ export class DefaultPackageManager implements PackageManager { return join(this.getTemporaryDir("npm"), "node_modules", source.name); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } return join(this.agentDir, "npm", "node_modules", source.name); @@ -1933,10 +1984,11 @@ export class DefaultPackageManager implements PackageManager { if (scope === "temporary") { return this.getTemporaryDir(`git-${source.host}`, source.path); } - if (scope === "project") { - return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path); + const installRoot = this.getGitInstallRoot(scope); + if (!installRoot) { + throw new Error("Missing git install root"); } - return join(this.agentDir, "git", source.host, source.path); + return this.resolveManagedPath(installRoot, source.host, source.path); } private getGitInstallRoot(scope: SourceScope): string | undefined { @@ -1944,21 +1996,33 @@ export class DefaultPackageManager implements PackageManager { return undefined; } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "git"); } return join(this.agentDir, "git"); } private getTemporaryDir(prefix: string, suffix?: string): string { + const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix); const hash = createHash("sha256") .update(`${prefix}-${suffix ?? ""}`) .digest("hex") .slice(0, 8); - return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? ""); + return this.resolveManagedPath(root, hash, suffix ?? ""); + } + + private resolveManagedPath(root: string, ...parts: string[]): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(resolvedRoot, ...parts); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) { + throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`); + } + return resolvedPath; } private getBaseDirForScope(scope: SourceScope): string { if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME); } if (scope === "user") { @@ -2220,9 +2284,10 @@ export class DefaultPackageManager implements PackageManager { themes: join(projectBaseDir, "themes"), }; const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); - const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter( - (dir) => resolve(dir) !== resolve(userAgentsSkillsDir), - ); + const projectTrusted = this.settingsManager.isProjectTrusted(); + const projectAgentsSkillDirs = projectTrusted + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + : []; const addResources = ( resourceType: ResourceType, @@ -2238,23 +2303,25 @@ export class DefaultPackageManager implements PackageManager { } }; - // Project extensions from .pi/ - addResources( - "extensions", - collectAutoExtensionEntries(projectDirs.extensions), - projectMetadata, - projectOverrides.extensions, - projectBaseDir, - ); + if (projectTrusted) { + // Project extensions from .pi/ + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); - // Project skills from .pi/ - addResources( - "skills", - collectAutoSkillEntries(projectDirs.skills, "pi"), - projectMetadata, - projectOverrides.skills, - projectBaseDir, - ); + // Project skills from .pi/ + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills, "pi"), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + } // Project skills from .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { @@ -2272,20 +2339,22 @@ export class DefaultPackageManager implements PackageManager { ); } - addResources( - "prompts", - collectAutoPromptEntries(projectDirs.prompts), - projectMetadata, - projectOverrides.prompts, - projectBaseDir, - ); - addResources( - "themes", - collectAutoThemeEntries(projectDirs.themes), - projectMetadata, - projectOverrides.themes, - projectBaseDir, - ); + if (projectTrusted) { + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } // User extensions from ~/.pi/agent/ addResources( diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts new file mode 100644 index 00000000..2521ad5d --- /dev/null +++ b/packages/coding-agent/src/core/project-trust.ts @@ -0,0 +1,96 @@ +import { CONFIG_DIR_NAME } from "../config.ts"; +import { emitProjectTrustEvent } from "./extensions/runner.ts"; +import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; +import type { DefaultProjectTrust } from "./settings-manager.ts"; +import { + getProjectTrustOptions, + hasTrustRequiringProjectResources, + type ProjectTrustOption, + type ProjectTrustStore, +} from "./trust-manager.ts"; + +export type AppMode = "interactive" | "print" | "json" | "rpc"; + +export interface ResolveProjectTrustedOptions { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + defaultProjectTrust?: DefaultProjectTrust; + extensionsResult?: LoadExtensionsResult; + projectTrustContext: ProjectTrustContext; + onExtensionError?: (message: string) => void; +} + +function formatProjectTrustPrompt(cwd: string): string { + return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; +} + +async function selectProjectTrustOption( + cwd: string, + ctx: ProjectTrustContext, +): Promise { + const options = getProjectTrustOptions(cwd, { includeSessionOnly: true }); + const selected = await ctx.ui.select( + formatProjectTrustPrompt(cwd), + options.map((option) => option.label), + ); + return options.find((option) => option.label === selected); +} + +function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void { + if (result.updates.length > 0) { + trustStore.setMany(result.updates); + } +} + +export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasTrustRequiringProjectResources(options.cwd)) { + return true; + } + + if (options.extensionsResult) { + const { result, errors } = await emitProjectTrustEvent( + options.extensionsResult, + { type: "project_trust", cwd: options.cwd }, + options.projectTrustContext, + ); + for (const error of errors) { + options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); + } + if (result) { + const trusted = result.trusted === "yes"; + if (result.remember === true) { + options.trustStore.set(options.cwd, trusted); + } + return trusted; + } + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + + switch (options.defaultProjectTrust ?? "ask") { + case "always": + return true; + case "never": + return false; + case "ask": + break; + } + + if (!options.projectTrustContext.hasUI) { + return false; + } + + const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext); + if (selected !== undefined) { + saveProjectTrustPromptResult(options.trustStore, selected); + return selected.trusted; + } + return false; +} diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 581a34eb..6b5b1e24 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -59,46 +59,45 @@ export function parseCommandArgs(argsString: string): string[] { * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args + * - ${N:-default} for positional arg N with default when missing/empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * - * Note: Replacement happens on the template string only. Argument values + * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */ export function substituteArgs(content: string, args: string[]): string { - let result = content; - - // Replace $1, $2, etc. with positional args FIRST (before wildcards) - // This prevents wildcard replacement values containing $ patterns from being re-substituted - result = result.replace(/\$(\d+)/g, (_, num) => { - const index = parseInt(num, 10) - 1; - return args[index] ?? ""; - }); - - // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) - // Process BEFORE simple $@ to avoid conflicts - result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { - let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) - // Treat 0 as 1 (bash convention: args start at 1) - if (start < 0) start = 0; - - if (lengthStr) { - const length = parseInt(lengthStr, 10); - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }); - - // Pre-compute all args joined (optimization) const allArgs = args.join(" "); - // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) - result = result.replace(/\$ARGUMENTS/g, allArgs); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultNum) { + const index = parseInt(defaultNum, 10) - 1; + const value = args[index]; + return value ? value : defaultValue; + } - // Replace $@ with all args joined (existing syntax) - result = result.replace(/\$@/g, allArgs); + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; - return result; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); } function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts new file mode 100644 index 00000000..97ad1cfa --- /dev/null +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -0,0 +1,109 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { SettingsManager } from "./settings-manager.ts"; +import { isInstallTelemetryEnabled } from "./telemetry.ts"; + +const OPENROUTER_HOST = "openrouter.ai"; +const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; +const CLOUDFLARE_API_HOST = "api.cloudflare.com"; +const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com"; +const OPENCODE_HOST = "opencode.ai"; +const VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh"; + +function matchesHost(baseUrl: string, expectedHost: string): boolean { + try { + return new URL(baseUrl).hostname === expectedHost; + } catch { + return false; + } +} + +function isOpenRouterModel(model: Model): boolean { + return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); +} + +function isNvidiaNimModel(model: Model): boolean { + return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); +} + +function isCloudflareModel(model: Model): boolean { + return ( + model.provider === "cloudflare-workers-ai" || + model.provider === "cloudflare-ai-gateway" || + matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) || + matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST) + ); +} + +function isVercelGatewayModel(model: Model): boolean { + return model.provider === "vercel-ai-gateway" || matchesHost(model.baseUrl, VERCEL_GATEWAY_HOST); +} + +function getDefaultAttributionHeaders( + model: Model, + settingsManager: SettingsManager, +): Record | undefined { + if (!isInstallTelemetryEnabled(settingsManager)) { + return undefined; + } + + if (isOpenRouterModel(model)) { + return { + "HTTP-Referer": "https://pi.dev", + "X-OpenRouter-Title": "pi", + "X-OpenRouter-Categories": "cli-agent", + }; + } + + if (isNvidiaNimModel(model)) { + return { + "X-BILLING-INVOKE-ORIGIN": "Pi", + }; + } + + if (isCloudflareModel(model)) { + return { + "User-Agent": "pi-coding-agent", + }; + } + + if (isVercelGatewayModel(model)) { + return { + "http-referer": "https://pi.dev", + "x-title": "pi", + }; + } + + return undefined; +} + +function getSessionHeaders(model: Model, sessionId: string | undefined): Record | undefined { + if (!sessionId) return undefined; + if ( + model.provider !== "opencode" && + model.provider !== "opencode-go" && + !matchesHost(model.baseUrl, OPENCODE_HOST) + ) { + return undefined; + } + return { "x-opencode-session": sessionId, "x-opencode-client": "pi" }; +} + +export function mergeProviderAttributionHeaders( + model: Model, + settingsManager: SettingsManager, + sessionId: string | undefined, + ...headerSources: Array | undefined> +): Record | undefined { + const merged = { + ...getSessionHeaders(model, sessionId), + ...getDefaultAttributionHeaders(model, settingsManager), + }; + + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 24f7b3ce..9b0371d2 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -1,6 +1,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { anthropic: "Anthropic", "amazon-bedrock": "Amazon Bedrock", + "ant-ling": "Ant Ling", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", "cloudflare-ai-gateway": "Cloudflare AI Gateway", @@ -17,6 +18,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "minimax-cn": "MiniMax (China)", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI (China)", + nvidia: "NVIDIA NIM", opencode: "OpenCode Zen", "opencode-go": "OpenCode Go", openai: "OpenAI", @@ -25,6 +27,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "vercel-ai-gateway": "Vercel AI Gateway", xai: "xAI", zai: "ZAI", + "zai-coding-cn": "ZAI Coding Plan (China)", xiaomi: "Xiaomi MiMo", "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 215914ba..6d75b001 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -8,27 +8,157 @@ import { getShellConfig } from "../utils/shell.ts"; // Cache for shell command results (persists for process lifetime) const commandResultCache = new Map(); +const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/; + +type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string }; + +type ConfigValueReference = { type: "command"; config: string } | { type: "template"; parts: TemplatePart[] }; + +function appendLiteral(parts: TemplatePart[], value: string): void { + if (!value) return; + const previousPart = parts[parts.length - 1]; + if (previousPart?.type === "literal") { + previousPart.value += value; + return; + } + parts.push({ type: "literal", value }); +} + +function parseConfigValueTemplate(config: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let index = 0; + + while (index < config.length) { + const dollarIndex = config.indexOf("$", index); + if (dollarIndex < 0) { + appendLiteral(parts, config.slice(index)); + break; + } + + appendLiteral(parts, config.slice(index, dollarIndex)); + const nextChar = config[dollarIndex + 1]; + + if (nextChar === "$" || nextChar === "!") { + appendLiteral(parts, nextChar); + index = dollarIndex + 2; + continue; + } + + if (nextChar === "{") { + const endIndex = config.indexOf("}", dollarIndex + 2); + if (endIndex < 0) { + appendLiteral(parts, "$"); + index = dollarIndex + 1; + continue; + } + + const name = config.slice(dollarIndex + 2, endIndex); + if (ENV_VAR_NAME_RE.test(name)) { + parts.push({ type: "env", name }); + } else { + appendLiteral(parts, config.slice(dollarIndex, endIndex + 1)); + } + index = endIndex + 1; + continue; + } + + const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE); + if (match) { + parts.push({ type: "env", name: match[0] }); + index = dollarIndex + 1 + match[0].length; + continue; + } + + appendLiteral(parts, "$"); + index = dollarIndex + 1; + } + + return parts; +} + +function parseConfigValueReference(config: string): ConfigValueReference { + if (config.startsWith("!")) { + return { type: "command", config }; + } + + return { type: "template", parts: parseConfigValueTemplate(config) }; +} + +function resolveEnvConfigValue(name: string, env?: Record): string | undefined { + return env?.[name] || process.env[name] || undefined; +} + +function getTemplateEnvVarNames(parts: TemplatePart[]): string[] { + const names: string[] = []; + for (const part of parts) { + if (part.type !== "env" || names.includes(part.name)) continue; + names.push(part.name); + } + return names; +} + +function resolveTemplate(parts: TemplatePart[], env?: Record): string | undefined { + let resolved = ""; + for (const part of parts) { + if (part.type === "literal") { + resolved += part.value; + continue; + } + const envValue = resolveEnvConfigValue(part.name, env); + if (envValue === undefined) return undefined; + resolved += envValue; + } + return resolved; +} + +export function getConfigValueEnvVarName(config: string): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type !== "template") return undefined; + return reference.parts.length === 1 && reference.parts[0]?.type === "env" ? reference.parts[0].name : undefined; +} + +export function getConfigValueEnvVarNames(config: string): string[] { + const reference = parseConfigValueReference(config); + return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : []; +} + +export function getMissingConfigValueEnvVarNames(config: string, env?: Record): string[] { + return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name, env) === undefined); +} + +export function isCommandConfigValue(config: string): boolean { + return parseConfigValueReference(config).type === "command"; +} + +export function isConfigValueConfigured(config: string, env?: Record): boolean { + return getMissingConfigValueEnvVarNames(config, env).length === 0; +} /** * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) - * - Otherwise checks environment variable first, then treats as literal (not cached) + * - Interpolates "$ENV_VAR" or "${ENV_VAR}" references with the named environment variable + * - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!" + * - Otherwise treats the value as a literal */ -export function resolveConfigValue(config: string): string | undefined { - if (config.startsWith("!")) { - return executeCommand(config); +export function resolveConfigValue(config: string, env?: Record): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommand(reference.config); } - const envValue = process.env[config]; - return envValue || config; + return resolveTemplate(reference.parts, env); } function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { try { - const { shell, args } = getShellConfig(); - const result = spawnSync(shell, [...args, command], { + const { shell, args, commandTransport } = getShellConfig(); + const commandFromStdin = commandTransport === "stdin"; + const result = spawnSync(shell, commandFromStdin ? args : [...args, command], { encoding: "utf-8", + input: commandFromStdin ? command : undefined, timeout: 10000, - stdio: ["ignore", "pipe", "ignore"], + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"], shell: false, windowsHide: true, }); @@ -88,22 +218,33 @@ function executeCommand(commandConfig: string): string | undefined { /** * Resolve all header values using the same resolution logic as API keys. */ -export function resolveConfigValueUncached(config: string): string | undefined { - if (config.startsWith("!")) { - return executeCommandUncached(config); +export function resolveConfigValueUncached(config: string, env?: Record): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommandUncached(reference.config); } - const envValue = process.env[config]; - return envValue || config; + return resolveTemplate(reference.parts, env); } -export function resolveConfigValueOrThrow(config: string, description: string): string { - const resolvedValue = resolveConfigValueUncached(config); +export function resolveConfigValueOrThrow(config: string, description: string, env?: Record): string { + const resolvedValue = resolveConfigValueUncached(config, env); if (resolvedValue !== undefined) { return resolvedValue; } - if (config.startsWith("!")) { - throw new Error(`Failed to resolve ${description} from shell command: ${config.slice(1)}`); + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + throw new Error(`Failed to resolve ${description} from shell command: ${reference.config.slice(1)}`); + } + + if (reference.type === "template") { + const missingEnvVars = getMissingConfigValueEnvVarNames(config, env); + if (missingEnvVars.length === 1) { + throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`); + } + if (missingEnvVars.length > 1) { + throw new Error(`Failed to resolve ${description} from environment variables: ${missingEnvVars.join(", ")}`); + } } throw new Error(`Failed to resolve ${description}`); @@ -112,11 +253,14 @@ export function resolveConfigValueOrThrow(config: string, description: string): /** * Resolve all header values using the same resolution logic as API keys. */ -export function resolveHeaders(headers: Record | undefined): Record | undefined { +export function resolveHeaders( + headers: Record | undefined, + env?: Record, +): Record | undefined { if (!headers) return undefined; const resolved: Record = {}; for (const [key, value] of Object.entries(headers)) { - const resolvedValue = resolveConfigValue(value); + const resolvedValue = resolveConfigValue(value, env); if (resolvedValue) { resolved[key] = resolvedValue; } @@ -127,11 +271,12 @@ export function resolveHeaders(headers: Record | undefined): Rec export function resolveHeadersOrThrow( headers: Record | undefined, description: string, + env?: Record, ): Record | undefined { if (!headers) return undefined; const resolved: Record = {}; for (const [key, value] of Object.entries(headers)) { - resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`); + resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`, env); } return Object.keys(resolved).length > 0 ? resolved : undefined; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index eb043cd5..18486ead 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -9,9 +9,14 @@ export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; -import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; +import { + clearExtensionCache, + createExtensionRuntime, + loadExtensionFromFactory, + loadExtensionsCached, +} from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts"; +import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -25,6 +30,10 @@ export interface ResourceExtensionPaths { themePaths?: Array<{ path: string; metadata: PathMetadata }>; } +export interface ResourceLoaderReloadOptions { + resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise; +} + export interface ResourceLoader { getExtensions(): LoadExtensionsResult; getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; @@ -34,7 +43,7 @@ export interface ResourceLoader { getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; extendResources(paths: ResourceExtensionPaths): void; - reload(): Promise; + reload(options?: ResourceLoaderReloadOptions): Promise; } function resolvePromptInput(input: string | undefined, description: string): string | undefined { @@ -202,6 +211,7 @@ export class DefaultResourceLoader implements ResourceLoader { private extensionThemeSourceInfos: Map; private lastPromptPaths: string[]; private lastThemePaths: string[]; + private loaded: boolean; constructor(options: DefaultResourceLoaderOptions) { this.cwd = resolvePath(options.cwd); @@ -248,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.extensionThemeSourceInfos = new Map(); this.lastPromptPaths = []; this.lastThemePaths = []; + this.loaded = false; } getExtensions(): LoadExtensionsResult { @@ -318,7 +329,27 @@ export class DefaultResourceLoader implements ResourceLoader { } } - async reload(): Promise { + async loadProjectTrustExtensions(): Promise { + // Force untrusted project settings for the bootstrap pass. This keeps project-local + // extensions/packages out while still loading user/global and temporary CLI extensions. + this.settingsManager.setProjectTrusted(false); + await this.settingsManager.reload(); + return this.loadCurrentExtensionSet({ includeInlineFactories: true }); + } + + async reload(options?: ResourceLoaderReloadOptions): Promise { + if (this.loaded) { + clearExtensionCache(); + } + + let preTrustExtensions: LoadExtensionsResult | undefined; + if (options?.resolveProjectTrust) { + preTrustExtensions = await this.loadProjectTrustExtensions(); + const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); + this.settingsManager.setProjectTrusted(projectTrusted); + } + + // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload(); const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { @@ -331,9 +362,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.extensionThemeSourceInfos = new Map(); // Helper to extract enabled paths and store metadata - const getEnabledResources = ( - resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, - ): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => { + const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => { for (const r of resources) { if (!metadataByPath.has(r.path)) { metadataByPath.set(r.path, r.metadata); @@ -342,37 +371,14 @@ export class DefaultResourceLoader implements ResourceLoader { return resources.filter((r) => r.enabled); }; - const getEnabledPaths = ( - resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, - ): string[] => getEnabledResources(resources).map((r) => r.path); + const getEnabledPaths = (resources: ResolvedResource[]): string[] => + getEnabledResources(resources).map((r) => r.path); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledThemes = getEnabledPaths(resolvedPaths.themes); - const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => { - if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { - return resource.path; - } - try { - const stats = statSync(resource.path); - if (!stats.isDirectory()) { - return resource.path; - } - } catch { - return resource.path; - } - const skillFile = join(resource.path, "SKILL.md"); - if (existsSync(skillFile)) { - if (!metadataByPath.has(skillFile)) { - metadataByPath.set(skillFile, resource.metadata); - } - return skillFile; - } - return resource.path; - }; - - const enabledSkills = enabledSkillResources.map(mapSkillPath); + const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath)); // Add CLI paths metadata for (const r of cliExtensionPaths.extensions) { @@ -395,18 +401,7 @@ export class DefaultResourceLoader implements ResourceLoader { ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions); - const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); - const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); - extensionsResult.extensions.push(...inlineExtensions.extensions); - extensionsResult.errors.push(...inlineExtensions.errors); - - // Detect extension conflicts (tools, commands, flags with same names from different extensions) - // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. - const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); - for (const conflict of conflicts) { - extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); - } - + const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions); for (const p of this.additionalExtensionPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); @@ -466,7 +461,12 @@ export class DefaultResourceLoader implements ResourceLoader { } const agentsFiles = { - agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }), + agentsFiles: this.noContextFiles + ? [] + : loadProjectContextFiles({ + cwd: this.cwd, + agentDir: this.agentDir, + }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; @@ -486,6 +486,116 @@ export class DefaultResourceLoader implements ResourceLoader { this.appendSystemPrompt = this.appendSystemPromptOverride ? this.appendSystemPromptOverride(baseAppend) : baseAppend; + this.loaded = true; + } + + private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise { + const resolvedPaths = await this.packageManager.resolve(); + const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { + temporary: true, + }); + const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const extensionPaths = this.noExtensions + ? cliEnabledExtensions + : this.mergePaths(cliEnabledExtensions, enabledExtensions); + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); + if (!options.includeInlineFactories) { + return extensionsResult; + } + + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + return extensionsResult; + } + + private resolveExtensionLoadPath(path: string): string { + return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); + } + + private async loadFinalExtensionSet( + extensionPaths: string[], + preTrustExtensions: LoadExtensionsResult | undefined, + ): Promise { + if (!preTrustExtensions) { + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + const preloadedByPath = new Map( + preTrustExtensions.extensions + .filter((extension) => !extension.path.startsWith(" [extension.resolvedPath, extension]), + ); + const failedPreloadPaths = new Set( + preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)), + ); + const remainingPaths = extensionPaths.filter((path) => { + const resolvedPath = this.resolveExtensionLoadPath(path); + return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); + }); + const remainingExtensions = await loadExtensionsCached( + remainingPaths, + this.cwd, + this.eventBus, + preTrustExtensions.runtime, + ); + const loadedByPath = new Map(preloadedByPath); + for (const extension of remainingExtensions.extensions) { + loadedByPath.set(extension.resolvedPath, extension); + } + + const inlineExtensions = preTrustExtensions.extensions.filter((extension) => + extension.path.startsWith(" loadedByPath.get(this.resolveExtensionLoadPath(path))) + .filter((extension): extension is Extension => extension !== undefined); + orderedExtensions.push(...inlineExtensions); + + const extensionsResult: LoadExtensionsResult = { + extensions: orderedExtensions, + errors: [...preTrustExtensions.errors, ...remainingExtensions.errors], + runtime: preTrustExtensions.runtime, + }; + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void { + // Detect extension conflicts (tools, commands, flags with same names from different extensions) + // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. + const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); + } + } + + private mapSkillPath(resource: ResolvedResource, metadataByPath: Map): string { + if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { + return resource.path; + } + try { + const stats = statSync(resource.path); + if (!stats.isDirectory()) { + return resource.path; + } + } catch { + return resource.path; + } + const skillFile = join(resource.path, "SKILL.md"); + if (existsSync(skillFile)) { + if (!metadataByPath.has(skillFile)) { + metadataByPath.set(skillFile, resource.metadata); + } + return skillFile; + } + return resource.path; } private normalizeExtensionPaths( @@ -852,7 +962,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } @@ -866,7 +976,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverAppendSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f19581e4..180846cb 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -11,11 +11,11 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi import { convertToLlm } from "./messages.ts"; import { ModelRegistry } from "./model-registry.ts"; import { findInitialModel } from "./model-resolver.ts"; +import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; -import { isInstallTelemetryEnabled } from "./telemetry.ts"; import { time } from "./timings.ts"; import { createBashTool, @@ -65,6 +65,8 @@ export interface CreateAgentSessionOptions { * When provided, only the listed tool names are enabled. */ tools?: string[]; + /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ + excludeTools?: string[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; @@ -126,36 +128,6 @@ function getDefaultAgentDir(): string { return getAgentDir(); } -function getAttributionHeaders( - model: Model, - settingsManager: SettingsManager, -): Record | undefined { - if (!isInstallTelemetryEnabled(settingsManager)) { - return undefined; - } - - if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) { - return { - "HTTP-Referer": "https://pi.dev", - "X-OpenRouter-Title": "pi", - "X-OpenRouter-Categories": "cli-agent", - }; - } - - if ( - model.provider === "cloudflare-workers-ai" || - model.provider === "cloudflare-ai-gateway" || - model.baseUrl.includes("api.cloudflare.com") || - model.baseUrl.includes("gateway.ai.cloudflare.com") - ) { - return { - "User-Agent": "pi-coding-agent", - }; - } - - return undefined; -} - /** * Create an AgentSession with the specified options. * @@ -271,11 +243,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); - const initialActiveToolNames: string[] = options.tools - ? [...options.tools] - : options.noTools - ? [] - : defaultActiveToolNames; + const excludedToolNames = options.excludeTools; + const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; + const initialActiveToolNames: string[] = ( + options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; @@ -331,18 +303,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!auth.ok) { throw new Error(auth.error); } + const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined; const providerRetrySettings = settingsManager.getProviderRetrySettings(); - const attributionHeaders = getAttributionHeaders(model, settingsManager); + const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); + // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". + // Use max int32 to effectively disable the timeout. + const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs; + const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs; + const websocketConnectTimeoutMs = + options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); return streamSimple(model, context, { ...options, apiKey: auth.apiKey, - timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, + env, + timeoutMs, + websocketConnectTimeoutMs, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, - headers: - attributionHeaders || auth.headers || options?.headers - ? { ...attributionHeaders, ...auth.headers, ...options?.headers } - : undefined, + headers: mergeProviderAttributionHeaders( + model, + settingsManager, + options?.sessionId, + auth.headers, + options?.headers, + ), }); }, onPayload: async (payload, _model) => { @@ -401,6 +385,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} modelRegistry, initialActiveToolNames, allowedToolNames, + excludedToolNames, extensionRunnerRef, sessionStartEvent: options.sessionStartEvent, }); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e7d12314..b07968b3 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -4,17 +4,19 @@ import { randomUUID } from "crypto"; import { appendFileSync, closeSync, + createReadStream, existsSync, mkdirSync, openSync, readdirSync, - readFileSync, readSync, statSync, writeFileSync, } from "fs"; -import { readdir, readFile, stat } from "fs/promises"; +import { readdir, stat } from "fs/promises"; import { join, resolve } from "path"; +import { createInterface } from "readline"; +import { StringDecoder } from "string_decoder"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { @@ -202,6 +204,14 @@ function createSessionId(): string { return uuidv7(); } +export function assertValidSessionId(id: string): void { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { + throw new Error( + "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character", + ); + } +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -347,9 +357,10 @@ export function buildSessionContext( const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { - path.unshift(current); + path.push(current); current = current.parentId ? byId.get(current.parentId) : undefined; } + path.reverse(); // Extract settings and find compaction let thinkingLevel = "off"; @@ -425,70 +436,120 @@ export function buildSessionContext( * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ -export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { +function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(resolvedAgentDir, "sessions", safePath); + return join(resolvedAgentDir, "sessions", safePath); +} + +export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir; } +const SESSION_READ_BUFFER_SIZE = 1024 * 1024; + +function parseSessionEntryLine(line: string): FileEntry | null { + if (!line.trim()) return null; + try { + return JSON.parse(line) as FileEntry; + } catch { + // Skip malformed lines + return null; + } +} + /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { const resolvedFilePath = normalizePath(filePath); if (!existsSync(resolvedFilePath)) return []; - const content = readFileSync(resolvedFilePath, "utf8"); const entries: FileEntry[] = []; - const lines = content.trim().split("\n"); + const fd = openSync(resolvedFilePath, "r"); + try { + const decoder = new StringDecoder("utf8"); + const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE); + let pending = ""; - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as FileEntry; - entries.push(entry); - } catch { - // Skip malformed lines + while (true) { + const bytesRead = readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + + pending += decoder.write(buffer.subarray(0, bytesRead)); + let lineStart = 0; + let newlineIndex = pending.indexOf("\n", lineStart); + while (newlineIndex !== -1) { + const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); + if (entry) entries.push(entry); + lineStart = newlineIndex + 1; + newlineIndex = pending.indexOf("\n", lineStart); + } + pending = pending.slice(lineStart); } + + pending += decoder.end(); + const finalEntry = parseSessionEntryLine(pending); + if (finalEntry) entries.push(finalEntry); + } finally { + closeSync(fd); } // Validate session header if (entries.length === 0) return entries; const header = entries[0]; - if (header.type !== "session" || typeof (header as any).id !== "string") { + if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { return []; } return entries; } -function isValidSessionFile(filePath: string): boolean { +function readSessionHeader(filePath: string): SessionHeader | null { try { const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(512); const bytesRead = readSync(fd, buffer, 0, 512, 0); closeSync(fd); const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; - if (!firstLine) return false; - const header = JSON.parse(firstLine); - return header.type === "session" && typeof header.id === "string"; + if (!firstLine) return null; + const header = JSON.parse(firstLine) as Record; + if (header.type !== "session" || typeof header.id !== "string") { + return null; + } + return header as unknown as SessionHeader; } catch { - return false; + return null; } } +function getSessionHeaderCwd(header: SessionHeader): string | undefined { + const cwd = (header as { cwd?: unknown }).cwd; + return typeof cwd === "string" ? cwd : undefined; +} + +function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean { + return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; +} + /** Exported for testing */ -export function findMostRecentSession(sessionDir: string): string | null { +export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { const resolvedSessionDir = normalizePath(sessionDir); + const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(resolvedSessionDir, f)) - .filter(isValidSessionFile) - .map((path) => ({ path, mtime: statSync(path).mtime })) + .map((path) => ({ path, header: readSessionHeader(path) })) + .filter( + (file): file is { path: string; header: SessionHeader } => + file.header !== null && + (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)), + ) + .map(({ path }) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; @@ -512,80 +573,59 @@ function extractTextContent(message: Message): string { .join(" "); } -function getLastActivityTime(entries: FileEntry[]): number | undefined { - let lastActivityTime: number | undefined; +function getMessageActivityTime(entry: SessionMessageEntry): number | undefined { + const message = entry.message; + if (!isMessageWithContent(message)) return undefined; + if (message.role !== "user" && message.role !== "assistant") return undefined; - for (const entry of entries) { - if (entry.type !== "message") continue; - - const message = (entry as SessionMessageEntry).message; - if (!isMessageWithContent(message)) continue; - if (message.role !== "user" && message.role !== "assistant") continue; - - const msgTimestamp = (message as { timestamp?: number }).timestamp; - if (typeof msgTimestamp === "number") { - lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp); - continue; - } - - const entryTimestamp = (entry as SessionEntryBase).timestamp; - if (typeof entryTimestamp === "string") { - const t = new Date(entryTimestamp).getTime(); - if (!Number.isNaN(t)) { - lastActivityTime = Math.max(lastActivityTime ?? 0, t); - } - } + const msgTimestamp = (message as { timestamp?: number }).timestamp; + if (typeof msgTimestamp === "number") { + return msgTimestamp; } - return lastActivityTime; -} - -function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date { - const lastActivityTime = getLastActivityTime(entries); - if (typeof lastActivityTime === "number" && lastActivityTime > 0) { - return new Date(lastActivityTime); - } - - const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; - return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime; + const t = new Date(entry.timestamp).getTime(); + return Number.isNaN(t) ? undefined : t; } async function buildSessionInfo(filePath: string): Promise { try { - const content = await readFile(filePath, "utf8"); - const entries: FileEntry[] = []; - const lines = content.trim().split("\n"); - - for (const line of lines) { - if (!line.trim()) continue; - try { - entries.push(JSON.parse(line) as FileEntry); - } catch { - // Skip malformed lines - } - } - - if (entries.length === 0) return null; - const header = entries[0]; - if (header.type !== "session") return null; - const stats = await stat(filePath); + let header: SessionHeader | null = null; let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; let name: string | undefined; + let lastActivityTime: number | undefined; + + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const entry = parseSessionEntryLine(line); + if (!entry) continue; + + if (!header) { + if (entry.type !== "session") return null; + header = entry; + continue; + } - for (const entry of entries) { // Extract session name (use latest, including explicit clears) if (entry.type === "session_info") { - const infoEntry = entry as SessionInfoEntry; - name = infoEntry.name?.trim() || undefined; + name = entry.name?.trim() || undefined; } if (entry.type !== "message") continue; messageCount++; - const message = (entry as SessionMessageEntry).message; + const activityTime = getMessageActivityTime(entry); + if (typeof activityTime === "number") { + lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime); + } + + const message = entry.message; if (!isMessageWithContent(message)) continue; if (message.role !== "user" && message.role !== "assistant") continue; @@ -598,18 +638,25 @@ async function buildSessionInfo(filePath: string): Promise { } } - const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : ""; - const parentSessionPath = (header as SessionHeader).parentSession; + if (!header) return null; - const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime); + const cwd = typeof header.cwd === "string" ? header.cwd : ""; + const parentSessionPath = header.parentSession; + const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; + const modified = + typeof lastActivityTime === "number" && lastActivityTime > 0 + ? new Date(lastActivityTime) + : !Number.isNaN(headerTime) + ? new Date(headerTime) + : stats.mtime; return { path: filePath, - id: (header as SessionHeader).id, + id: header.id, cwd, name, parentSessionPath, - created: new Date((header as SessionHeader).timestamp), + created: new Date(header.timestamp), modified, messageCount, firstMessage: firstMessage || "(no messages)", @@ -721,7 +768,13 @@ export class SessionManager { private labelTimestampsById: Map = new Map(); private leafId: string | null = null; - private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { + private constructor( + cwd: string, + sessionDir: string, + sessionFile: string | undefined, + persist: boolean, + newSessionOptions?: NewSessionOptions, + ) { this.cwd = resolvePath(cwd); this.sessionDir = normalizePath(sessionDir); this.persist = persist; @@ -732,7 +785,7 @@ export class SessionManager { if (sessionFile) { this.setSessionFile(sessionFile); } else { - this.newSession(); + this.newSession(newSessionOptions); } } @@ -770,6 +823,9 @@ export class SessionManager { } newSession(options?: NewSessionOptions): string | undefined { + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } this.sessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -816,8 +872,14 @@ export class SessionManager { private _rewriteFile(): void { if (!this.persist || !this.sessionFile) return; - const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; - writeFileSync(this.sessionFile, content); + const fd = openSync(this.sessionFile, "w"); + try { + for (const entry of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(entry)}\n`); + } + } finally { + closeSync(fd); + } } isPersisted(): boolean { @@ -832,6 +894,10 @@ export class SessionManager { return this.sessionDir; } + usesDefaultSessionDir(): boolean { + return this.sessionDir === getDefaultSessionDirPath(this.cwd); + } + getSessionId(): string { return this.sessionId; } @@ -845,14 +911,23 @@ export class SessionManager { const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) { - // Mark as not flushed so when assistant arrives, all entries get written - this.flushed = false; + if (this.flushed) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } else { + // Mark as not flushed so when assistant arrives, all entries get written + this.flushed = false; + } return; } if (!this.flushed) { - for (const e of this.fileEntries) { - appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); + const fd = openSync(this.sessionFile, "wx"); + try { + for (const e of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(e)}\n`); + } + } finally { + closeSync(fd); } this.flushed = true; } else { @@ -1078,9 +1153,10 @@ export class SessionManager { const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; while (current) { - path.unshift(current); + path.push(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; } + path.reverse(); return path; } @@ -1216,8 +1292,16 @@ export class SessionManager { throw new Error(`Entry ${leafId} not found`); } - // Filter out LabelEntry from path - we'll recreate them from the resolved map - const pathWithoutLabels = path.filter((e) => e.type !== "label"); + // Filter out LabelEntry from path - we'll recreate them from the resolved map. + // Because labels are real tree entries, later entries can be children of labels; + // removing labels requires re-chaining the retained path to avoid orphaned subtrees. + const pathWithoutLabels: SessionEntry[] = []; + let pathParentId: string | null = null; + for (const entry of path) { + if (entry.type === "label") continue; + pathWithoutLabels.push({ ...entry, parentId: pathParentId }); + pathParentId = entry.id; + } const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); @@ -1308,9 +1392,9 @@ export class SessionManager { * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ - static create(cwd: string, sessionDir?: string): SessionManager { + static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - return new SessionManager(cwd, dir, undefined, true); + return new SessionManager(cwd, dir, undefined, true, options); } /** @@ -1337,7 +1421,8 @@ export class SessionManager { */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const mostRecent = findMostRecentSession(dir); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } @@ -1356,7 +1441,12 @@ export class SessionManager { * @param targetCwd Target working directory (where the new session will be stored) * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ - static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { + static forkFrom( + sourcePath: string, + targetCwd: string, + sessionDir?: string, + options?: NewSessionOptions, + ): SessionManager { const resolvedSourcePath = resolvePath(sourcePath); const resolvedTargetCwd = resolvePath(targetCwd); const sourceEntries = loadEntriesFromFile(resolvedSourcePath); @@ -1375,7 +1465,10 @@ export class SessionManager { } // Create new session file with new ID but forked content - const newSessionId = createSessionId(); + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } + const newSessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); @@ -1389,7 +1482,7 @@ export class SessionManager { cwd: resolvedTargetCwd, parentSession: resolvedSourcePath, }; - appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); + writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" }); // Copy all non-header entries from source for (const entry of sourceEntries) { @@ -1409,7 +1502,11 @@ export class SessionManager { */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const sessions = await listSessionsFromDir(dir, onProgress); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const resolvedCwd = resolvePath(cwd); + const sessions = (await listSessionsFromDir(dir, onProgress)).filter( + (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), + ); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } @@ -1418,7 +1515,21 @@ export class SessionManager { * List all sessions across all project directories. * @param onProgress Optional callback for progress updates (loaded, total) */ - static async listAll(onProgress?: SessionListProgress): Promise { + static async listAll(onProgress?: SessionListProgress): Promise; + static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise; + static async listAll( + sessionDirOrOnProgress?: string | SessionListProgress, + onProgress?: SessionListProgress, + ): Promise { + const customSessionDir = + typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; + const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; + if (customSessionDir) { + const sessions = await listSessionsFromDir(customSessionDir, progress); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + const sessionsDir = getSessionsDir(); try { @@ -1448,7 +1559,7 @@ export class SessionManager { const results = await buildSessionInfosWithConcurrency(allFiles, () => { loaded++; - onProgress?.(loaded, totalFiles); + progress?.(loaded, totalFiles); }); for (const info of results) { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1c488718..40838aad 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,4 +1,5 @@ import type { Transport } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; @@ -57,6 +58,8 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export type DefaultProjectTrust = "ask" | "always" | "never"; + export type TransportSetting = Transport; /** @@ -90,10 +93,13 @@ export interface Settings { shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; showChangelogOnStartup?: boolean; + defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + enableAnalytics?: boolean; // default: false - opt-in analytics data sharing + trackingId?: string; // analytics tracking identifier, generated when analytics is enabled packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) extensions?: string[]; // Array of local extension file paths or directories skills?: string[]; // Array of local skill file paths or directories @@ -112,7 +118,9 @@ export interface Settings { markdown?: MarkdownSettings; warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it + websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -146,8 +154,23 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings { return result; } +function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid ${settingName} setting: ${String(value)}`); + } + return undefined; +} + export type SettingsScope = "global" | "project"; +export interface SettingsManagerCreateOptions { + projectTrusted?: boolean; +} + export interface SettingsStorage { withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; } @@ -248,6 +271,7 @@ export class SettingsManager { private globalSettings: Settings; private projectSettings: Settings; private settings: Settings; + private projectTrusted: boolean; private modifiedFields = new Set(); // Track global fields modified during session private modifiedNestedFields = new Map>(); // Track global nested field modifications private modifiedProjectFields = new Set(); // Track project fields modified during session @@ -264,10 +288,12 @@ export class SettingsManager { globalLoadError: Error | null = null, projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], + projectTrusted = true, ) { this.storage = storage; this.globalSettings = initialGlobal; this.projectSettings = initialProject; + this.projectTrusted = projectTrusted; this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; @@ -275,15 +301,20 @@ export class SettingsManager { } /** Create a SettingsManager that loads from files */ - static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { + static create( + cwd: string, + agentDir: string = getAgentDir(), + options: SettingsManagerCreateOptions = {}, + ): SettingsManager { const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage); + return SettingsManager.fromStorage(storage, options); } /** Create a SettingsManager from an arbitrary storage backend */ - static fromStorage(storage: SettingsStorage): SettingsManager { + static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); - const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { initialErrors.push({ scope: "global", error: globalLoad.error }); @@ -299,6 +330,7 @@ export class SettingsManager { globalLoad.error, projectLoad.error, initialErrors, + projectTrusted, ); } @@ -310,7 +342,11 @@ export class SettingsManager { return SettingsManager.fromStorage(storage); } - private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings { + private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings { + if (scope === "project" && !projectTrusted) { + return {}; + } + let content: string | undefined; storage.withLock(scope, (current) => { content = current; @@ -327,9 +363,10 @@ export class SettingsManager { private static tryLoadFromStorage( storage: SettingsStorage, scope: SettingsScope, + projectTrusted = true, ): { settings: Settings; error: Error | null } { try { - return { settings: SettingsManager.loadFromStorage(storage, scope), error: null }; + return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; } catch (error) { return { settings: {}, error: error as Error }; } @@ -405,6 +442,35 @@ export class SettingsManager { return structuredClone(this.projectSettings); } + isProjectTrusted(): boolean { + return this.projectTrusted; + } + + setProjectTrusted(trusted: boolean): void { + if (this.projectTrusted === trusted) { + return; + } + + this.projectTrusted = trusted; + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + if (!trusted) { + this.projectSettings = {}; + this.projectSettingsLoadError = null; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + return; + } + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted); + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = projectLoad.error; + if (projectLoad.error) { + this.recordError("project", projectLoad.error); + } + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + async reload(): Promise { await this.writeQueue; const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); @@ -421,7 +487,7 @@ export class SettingsManager { this.modifiedProjectFields.clear(); this.modifiedProjectNestedFields.clear(); - const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted); if (!projectLoad.error) { this.projectSettings = projectLoad.settings; this.projectSettingsLoadError = null; @@ -460,6 +526,12 @@ export class SettingsManager { } } + private assertProjectTrustedForWrite(): void { + if (!this.projectTrusted) { + throw new Error("Project is not trusted; refusing to write project settings"); + } + } + private recordError(scope: SettingsScope, error: unknown): void { const normalizedError = error instanceof Error ? error : new Error(String(error)); this.errors.push({ scope, error: normalizedError }); @@ -479,6 +551,9 @@ export class SettingsManager { private enqueueWrite(scope: SettingsScope, task: () => void): void { this.writeQueue = this.writeQueue .then(() => { + if (scope === "project") { + this.assertProjectTrustedForWrite(); + } task(); this.clearModifiedScope(scope); }) @@ -543,6 +618,7 @@ export class SettingsManager { } private saveProjectSettings(settings: Settings): void { + this.assertProjectTrustedForWrite(); this.projectSettings = structuredClone(settings); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); @@ -558,6 +634,14 @@ export class SettingsManager { }); } + private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void { + this.assertProjectTrustedForWrite(); + const projectSettings = structuredClone(this.projectSettings); + update(projectSettings); + this.markProjectModified(field); + this.saveProjectSettings(projectSettings); + } + async flush(): Promise { await this.writeQueue; } @@ -631,8 +715,15 @@ export class SettingsManager { this.save(); } + getThemeSetting(): string | undefined { + const value = this.settings.theme; + if (typeof value === "string") return value; + return undefined; + } + getTheme(): string | undefined { - return this.settings.theme; + const theme = this.getThemeSetting(); + return theme?.includes("/") ? undefined : theme; } setTheme(theme: string): void { @@ -723,15 +814,7 @@ export class SettingsManager { } getHttpIdleTimeoutMs(): number { - const value = this.settings.httpIdleTimeoutMs; - const timeoutMs = parseHttpIdleTimeoutMs(value); - if (timeoutMs !== undefined) { - return timeoutMs; - } - if (value !== undefined) { - throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`); - } - return DEFAULT_HTTP_IDLE_TIMEOUT_MS; + return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS; } setHttpIdleTimeoutMs(timeoutMs: number): void { @@ -751,6 +834,10 @@ export class SettingsManager { }; } + getWebSocketConnectTimeoutMs(): number | undefined { + return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs"); + } + getHideThinkingBlock(): boolean { return this.settings.hideThinkingBlock ?? false; } @@ -791,6 +878,17 @@ export class SettingsManager { this.save(); } + getDefaultProjectTrust(): DefaultProjectTrust { + const value = this.globalSettings.defaultProjectTrust; + return value === "always" || value === "never" ? value : "ask"; + } + + setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void { + this.globalSettings.defaultProjectTrust = defaultProjectTrust; + this.markModified("defaultProjectTrust"); + this.save(); + } + getShellCommandPrefix(): string | undefined { return this.settings.shellCommandPrefix; } @@ -831,6 +929,25 @@ export class SettingsManager { this.save(); } + getEnableAnalytics(): boolean { + return this.settings.enableAnalytics ?? false; + } + + getTrackingId(): string | undefined { + return this.settings.trackingId; + } + + /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */ + setEnableAnalytics(enabled: boolean): void { + this.globalSettings.enableAnalytics = enabled; + this.markModified("enableAnalytics"); + if (enabled && !this.globalSettings.trackingId) { + this.globalSettings.trackingId = randomUUID(); + this.markModified("trackingId"); + } + this.save(); + } + getPackages(): PackageSource[] { return [...(this.settings.packages ?? [])]; } @@ -842,10 +959,9 @@ export class SettingsManager { } setProjectPackages(packages: PackageSource[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.packages = packages; - this.markProjectModified("packages"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("packages", (settings) => { + settings.packages = packages; + }); } getExtensionPaths(): string[] { @@ -859,10 +975,9 @@ export class SettingsManager { } setProjectExtensionPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.extensions = paths; - this.markProjectModified("extensions"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("extensions", (settings) => { + settings.extensions = paths; + }); } getSkillPaths(): string[] { @@ -876,10 +991,9 @@ export class SettingsManager { } setProjectSkillPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.skills = paths; - this.markProjectModified("skills"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("skills", (settings) => { + settings.skills = paths; + }); } getPromptTemplatePaths(): string[] { @@ -893,10 +1007,9 @@ export class SettingsManager { } setProjectPromptTemplatePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.prompts = paths; - this.markProjectModified("prompts"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("prompts", (settings) => { + settings.prompts = paths; + }); } getThemePaths(): string[] { @@ -910,10 +1023,9 @@ export class SettingsManager { } setProjectThemePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.themes = paths; - this.markProjectModified("themes"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("themes", (settings) => { + settings.themes = paths; + }); } getEnableSkillCommands(): boolean { diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index e52892c8..77dd79aa 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "trust", description: "Save project trust decision for future sessions" }, { name: "login", description: "Configure provider authentication" }, { name: "logout", description: "Remove provider authentication" }, { name: "new", description: "Start a new session" }, diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 8664d871..7580782b 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -112,8 +112,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { // File exploration guidelines if (hasBash && !hasGrep && !hasFind && !hasLs) { addGuideline("Use bash for file operations like ls, rg, find"); - } else if (hasBash && (hasGrep || hasFind || hasLs)) { - addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); } for (const guideline of promptGuidelines ?? []) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b9e55142..da6934e7 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; @@ -64,23 +65,37 @@ export interface BashOperations { */ export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { return { - exec: (command, cwd, { onData, signal, timeout, env }) => { - return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(options?.shellPath); - if (!existsSync(cwd)) { - reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); - return; - } - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - if (child.pid) trackDetachedChildPid(child.pid); - let timedOut = false; - let timeoutHandle: NodeJS.Timeout | undefined; + exec: async (command, cwd, { onData, signal, timeout, env }) => { + const shellConfig = getShellConfig(options?.shellPath); + try { + await fsAccess(cwd, constants.F_OK); + } catch { + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + } + if (signal?.aborted) { + throw new Error("aborted"); + } + + const commandFromStdin = shellConfig.commandTransport === "stdin"; + const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } + if (child.pid) trackDetachedChildPid(child.pid); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + + try { // Set timeout if provided. if (timeout !== undefined && timeout > 0) { timeoutHandle = setTimeout(() => { @@ -92,37 +107,25 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas child.stdout?.on("data", onData); child.stderr?.on("data", onData); // Handle abort signal by killing the entire process tree. - const onAbort = () => { - if (child.pid) killProcessTree(child.pid); - }; if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); } // Handle shell spawn errors and wait for the process to terminate without hanging // on inherited stdio handles held by detached descendants. - waitForChildProcess(child) - .then((code) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - if (timedOut) { - reject(new Error(`timeout:${timeout}`)); - return; - } - resolve({ exitCode: code }); - }) - .catch((err) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - reject(err); - }); - }); + const exitCode = await waitForChildProcess(child); + if (signal?.aborted) { + throw new Error("aborted"); + } + if (timedOut) { + throw new Error(`timeout:${timeout}`); + } + return { exitCode }; + } finally { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + } }, }; } @@ -230,7 +233,7 @@ function rebuildBashResultRenderComponent( if (state.cachedSkipped && state.cachedSkipped > 0) { const hint = theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + - ` ${keyHint("app.tools.expand", "to expand")})`; + ` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; } return ["", ...(state.cachedLines ?? [])]; @@ -291,6 +294,7 @@ export function createBashToolDefinition( const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; let lastUpdateAt = 0; @@ -336,11 +340,13 @@ export function createBashToolDefinition( } const handleData = (data: Buffer) => { + if (!acceptingOutput) return; output.append(data); scheduleOutputUpdate(); }; const finishOutput = async () => { + acceptingOutput = false; output.finish(); clearUpdateTimer(); emitOutputUpdate(); diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index f280bf19..5a4d966b 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -1,6 +1,5 @@ /** - * Shared diff computation utilities for the edit tool. - * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). + * Shared diff computation utilities for the edit and similar tools. */ import * as Diff from "diff"; @@ -54,6 +53,124 @@ export function normalizeForFuzzyMatch(text: string): string { ); } +function splitLinesWithEndings(content: string): string[] { + return content.match(/[^\n]*\n|[^\n]+/g) ?? []; +} + +interface LineSpan { + start: number; + end: number; +} + +interface MatchedEdit { + editIndex: number; + matchIndex: number; + matchLength: number; + newText: string; +} + +type TextReplacement = Pick; + +function getLineSpans(content: string): LineSpan[] { + let offset = 0; + return splitLinesWithEndings(content).map((line) => { + const span = { start: offset, end: offset + line.length }; + offset = span.end; + return span; + }); +} + +function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) { + const replacementStart = replacement.matchIndex; + const replacementEnd = replacement.matchIndex + replacement.matchLength; + + let startLine = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (replacementStart >= line.start && replacementStart < line.end) { + startLine = i; + break; + } + } + if (startLine === -1) { + throw new Error("Replacement range is outside the base content."); + } + + let endLine = startLine; + while (endLine < lines.length && lines[endLine].end < replacementEnd) { + endLine++; + } + if (endLine >= lines.length) { + throw new Error("Replacement range is outside the base content."); + } + + return { startLine, endLine: endLine + 1 }; +} + +function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string { + let result = content; + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + const matchIndex = replacement.matchIndex - offset; + result = + result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength); + } + return result; +} + +/** + * Apply replacements matched against `baseContent` to `originalContent` while + * preserving unchanged line blocks from the original. + * + * This is useful when `baseContent` is a normalized view of the original. Each + * replacement is widened to the lines it actually touches, those touched lines + * are rewritten from the normalized base, and all other lines are copied back + * from `originalContent`. The actual replacement ranges drive preservation so + * duplicate normalized lines cannot be aligned to the wrong occurrence. + */ +export function applyReplacementsPreservingUnchangedLines( + originalContent: string, + baseContent: string, + replacements: TextReplacement[], +): string { + const originalLines = splitLinesWithEndings(originalContent); + const baseLines = getLineSpans(baseContent); + if (originalLines.length !== baseLines.length) { + throw new Error("Cannot preserve unchanged lines because the base content has a different line count."); + } + + const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = []; + const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex); + for (const replacement of sortedReplacements) { + const range = getReplacementLineRange(baseLines, replacement); + const current = groups[groups.length - 1]; + if (current && range.startLine < current.endLine) { + current.endLine = Math.max(current.endLine, range.endLine); + current.replacements.push(replacement); + continue; + } + groups.push({ ...range, replacements: [replacement] }); + } + + let originalLineIndex = 0; + let result = ""; + for (const group of groups) { + result += originalLines.slice(originalLineIndex, group.startLine).join(""); + + const groupStartOffset = baseLines[group.startLine].start; + const groupEndOffset = baseLines[group.endLine - 1].end; + result += applyReplacements( + baseContent.slice(groupStartOffset, groupEndOffset), + group.replacements, + groupStartOffset, + ); + originalLineIndex = group.endLine; + } + result += originalLines.slice(originalLineIndex).join(""); + + return result; +} + export interface FuzzyMatchResult { /** Whether a match was found */ found: boolean; @@ -75,13 +192,6 @@ export interface Edit { newText: string; } -interface MatchedEdit { - editIndex: number; - matchIndex: number; - matchLength: number; - newText: string; -} - export interface AppliedEditsResult { baseContent: string; newContent: string; @@ -121,9 +231,9 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul }; } - // When fuzzy matching, we work in the normalized space for replacement. - // This means the output will have normalized whitespace/quotes/dashes, - // which is acceptable since we're fixing minor formatting differences anyway. + // When fuzzy matching, return offsets in normalized space. Callers can use + // the normalized content to compute replacements, then decide how much of + // that normalized output should be written back. return { found: true, index: fuzzyIndex, @@ -187,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error { * * All edits are matched against the same original content. Replacements are * then applied in reverse order so offsets remain stable. If any edit needs - * fuzzy matching, the operation runs in fuzzy-normalized content space to - * preserve current single-edit behavior. + * fuzzy matching, the operation runs in fuzzy-normalized content space and then + * overlays those line-level changes onto the original content so unchanged line + * blocks keep their original bytes. */ export function applyEditsToNormalizedContent( normalizedContent: string, @@ -207,19 +318,18 @@ export function applyEditsToNormalizedContent( } const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)); - const baseContent = initialMatches.some((match) => match.usedFuzzyMatch) - ? normalizeForFuzzyMatch(normalizedContent) - : normalizedContent; + const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch); + const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent; const matchedEdits: MatchedEdit[] = []; for (let i = 0; i < normalizedEdits.length; i++) { const edit = normalizedEdits[i]; - const matchResult = fuzzyFindText(baseContent, edit.oldText); + const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); if (!matchResult.found) { throw getNotFoundError(path, i, normalizedEdits.length); } - const occurrences = countOccurrences(baseContent, edit.oldText); + const occurrences = countOccurrences(replacementBaseContent, edit.oldText); if (occurrences > 1) { throw getDuplicateError(path, i, normalizedEdits.length, occurrences); } @@ -243,14 +353,10 @@ export function applyEditsToNormalizedContent( } } - let newContent = baseContent; - for (let i = matchedEdits.length - 1; i >= 0; i--) { - const edit = matchedEdits[i]; - newContent = - newContent.substring(0, edit.matchIndex) + - edit.newText + - newContent.substring(edit.matchIndex + edit.matchLength); - } + const baseContent = normalizedContent; + const newContent = usedFuzzyMatch + ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) + : applyReplacements(replacementBaseContent, matchedEdits); if (baseContent === newContent) { throw getNoChangeError(path, normalizedEdits.length); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 5d915d45..25aa1e17 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -4,6 +4,7 @@ import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { applyEditsToNormalizedContent, @@ -20,7 +21,7 @@ import { } from "./edit-diff.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; -import { invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { renderToolPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; type EditPreview = EditDiffResult | EditDiffError; @@ -191,14 +192,8 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path return null; } -function formatEditCall( - args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { - const invalidArg = invalidArgText(theme); - const rawPath = str(args?.file_path ?? args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); +function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; } @@ -206,7 +201,7 @@ function formatEditResult( args: RenderableEditArgs | undefined, preview: EditPreview | undefined, result: EditToolResultLike, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, isError: boolean, ): string | undefined { const rawPath = str(args?.file_path ?? args?.path); @@ -234,7 +229,7 @@ function formatEditResult( function getEditHeaderBg( preview: EditPreview | undefined, settledError: boolean | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): (text: string) => string { if (preview) { if ("error" in preview) { @@ -251,11 +246,12 @@ function getEditHeaderBg( function buildEditCallComponent( component: EditCallRenderComponent, args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, + cwd: string, ): EditCallRenderComponent { component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); - component.addChild(new Text(formatEditCall(args, theme), 0, 0)); + component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0)); if (!component.preview) { return component; @@ -313,113 +309,56 @@ export function createEditToolDefinition( const { path, edits } = validateEditInput(input); const absolutePath = resolveToCwd(path, cwd); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ - content: Array<{ type: "text"; text: string }>; - details: EditToolDetails | undefined; - }>((resolve, reject) => { - // Check if already aborted. - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } + return withFileMutationQueue(absolutePath, async () => { + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. + const throwIfAborted = (): void => { + if (signal?.aborted) throw new Error("Operation aborted"); + }; - let aborted = false; + throwIfAborted(); - // Set up abort handler. - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; + // Check if file exists. + try { + await ops.access(absolutePath); + } catch (error: unknown) { + throwIfAborted(); + const errorMessage = + error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); + } + throwIfAborted(); - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } + // Read the file. + const buffer = await ops.readFile(absolutePath); + const rawContent = buffer.toString("utf-8"); + throwIfAborted(); - // Perform the edit operation. - void (async () => { - try { - // Check if file exists. - try { - await ops.access(absolutePath); - } catch (error: unknown) { - const errorMessage = - error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`)); - return; - } + // Strip BOM before matching. The model will not include an invisible BOM in oldText. + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + throwIfAborted(); - // Check if aborted before reading. - if (aborted) { - return; - } + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + throwIfAborted(); - // Read the file. - const buffer = await ops.readFile(absolutePath); - const rawContent = buffer.toString("utf-8"); - - // Check if aborted after reading. - if (aborted) { - return; - } - - // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); - const originalEnding = detectLineEnding(content); - const normalizedContent = normalizeToLF(content); - const { baseContent, newContent } = applyEditsToNormalizedContent( - normalizedContent, - edits, - path, - ); - - // Check if aborted before writing. - if (aborted) { - return; - } - - const finalContent = bom + restoreLineEndings(newContent, originalEnding); - await ops.writeFile(absolutePath, finalContent); - - // Check if aborted after writing. - if (aborted) { - return; - } - - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - resolve({ - content: [ - { - type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, - }, - ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, - }); - } catch (error: unknown) { - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (!aborted) { - reject(error instanceof Error ? error : new Error(String(error))); - } - } - })(); - }), - ); + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + }; + }); }, renderCall(args, theme, context) { const component = getEditCallRenderComponent(context.state, context.lastComponent); @@ -446,7 +385,7 @@ export function createEditToolDefinition( }); } - return buildEditCallComponent(component, args, theme); + return buildEditCallComponent(component, args, theme, context.cwd); }, renderResult(result, _options, theme, context) { const callComponent = context.state.callComponent; @@ -471,7 +410,12 @@ export function createEditToolDefinition( changed = true; } if (changed) { - buildEditCallComponent(callComponent, context.args as RenderableEditArgs | undefined, theme); + buildEditCallComponent( + callComponent, + context.args as RenderableEditArgs | undefined, + theme, + context.cwd, + ); } } diff --git a/packages/coding-agent/src/core/tools/file-mutation-queue.ts b/packages/coding-agent/src/core/tools/file-mutation-queue.ts index 22011255..5505a7a2 100644 --- a/packages/coding-agent/src/core/tools/file-mutation-queue.ts +++ b/packages/coding-agent/src/core/tools/file-mutation-queue.ts @@ -1,14 +1,27 @@ -import { realpathSync } from "node:fs"; +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; const fileMutationQueues = new Map>(); +let registrationQueue = Promise.resolve(); -function getMutationQueueKey(filePath: string): string { +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function getMutationQueueKey(filePath: string): Promise { const resolvedPath = resolve(filePath); try { - return realpathSync.native(resolvedPath); - } catch { - return resolvedPath; + return await realpath(resolvedPath); + } catch (error) { + if (isMissingPathError(error)) { + return resolvedPath; + } + throw error; } } @@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string { * Operations for different files still run in parallel. */ export async function withFileMutationQueue(filePath: string, fn: () => Promise): Promise { - const key = getMutationQueueKey(filePath); - const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); + const registration = registrationQueue.then(async () => { + const key = await getMutationQueueKey(filePath); + const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); - let releaseNext!: () => void; - const nextQueue = new Promise((resolveQueue) => { - releaseNext = resolveQueue; + let releaseNext!: () => void; + const nextQueue = new Promise((resolveQueue) => { + releaseNext = resolveQueue; + }); + const chainedQueue = currentQueue.then(() => nextQueue); + fileMutationQueues.set(key, chainedQueue); + + return { key, currentQueue, chainedQueue, releaseNext }; }); - const chainedQueue = currentQueue.then(() => nextQueue); - fileMutationQueues.set(key, chainedQueue); + registrationQueue = registration.then( + () => undefined, + () => undefined, + ); + const { key, currentQueue, chainedQueue, releaseNext } = await registration; await currentQueue; try { return await fn(); diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 3fd2f683..6f852f61 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -2,13 +2,13 @@ import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { existsSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveToCwd } from "./path-utils.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -46,7 +46,7 @@ export interface FindOperations { } const defaultFindOperations: FindOperations = { - exists: existsSync, + exists: pathExists, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; @@ -56,10 +56,7 @@ export interface FindToolOptions { operations?: FindOperations; } -function formatFindCall( - args: { pattern: string; path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { +function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); const path = rawPath !== null ? shortenPath(rawPath || ".") : null; @@ -82,7 +79,7 @@ function formatFindResult( details?: FindToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); @@ -94,7 +91,7 @@ function formatFindResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 041b3148..e4ed36d1 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,11 +1,12 @@ +import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { readFileSync, statSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -55,8 +56,8 @@ export interface GrepOperations { } const defaultGrepOperations: GrepOperations = { - isDirectory: (p) => statSync(p).isDirectory(), - readFile: (p) => readFileSync(p, "utf-8"), + isDirectory: async (p) => (await fsStat(p)).isDirectory(), + readFile: (p) => fsReadFile(p, "utf-8"), }; export interface GrepToolOptions { @@ -66,7 +67,7 @@ export interface GrepToolOptions { function formatGrepCall( args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); @@ -90,7 +91,7 @@ function formatGrepResult( details?: GrepToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); @@ -102,7 +103,7 @@ function formatGrepResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index f3d22c1d..8a689e8d 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,12 +1,13 @@ +import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; -import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveToCwd } from "./path-utils.ts"; -import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, renderToolPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -38,9 +39,9 @@ export interface LsOperations { } const defaultLsOperations: LsOperations = { - exists: existsSync, - stat: statSync, - readdir: readdirSync, + exists: pathExists, + stat: fsStat, + readdir: fsReaddir, }; export interface LsToolOptions { @@ -48,15 +49,10 @@ export interface LsToolOptions { operations?: LsOperations; } -function formatLsCall( - args: { path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { - const rawPath = str(args?.path); - const path = rawPath !== null ? shortenPath(rawPath || ".") : null; +function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string { const limit = args?.limit; - const invalidArg = invalidArgText(theme); - let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; + const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." }); + let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`; if (limit !== undefined) { text += theme.fg("toolOutput", ` (limit ${limit})`); } @@ -69,7 +65,7 @@ function formatLsResult( details?: LsToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); @@ -81,7 +77,7 @@ function formatLsResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } @@ -213,7 +209,7 @@ export function createLsToolDefinition( }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatLsCall(args, theme)); + text.setText(formatLsCall(args, theme, context.cwd)); return text; }, renderResult(result, options, theme, context) { diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 1ed1e2bc..1f9ab4cc 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,4 +1,5 @@ import { accessSync, constants } from "node:fs"; +import { access } from "node:fs/promises"; import { normalizePath, resolvePath } from "../../utils/paths.ts"; const NARROW_NO_BREAK_SPACE = "\u202F"; @@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean { } } +export async function pathExists(filePath: string): Promise { + try { + await access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + export function expandPath(filePath: string): string { return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } @@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string { return resolved; } + +export async function resolveReadPathAsync(filePath: string, cwd: string): Promise { + const resolved = resolveToCwd(filePath, cwd); + + if (await pathExists(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && (await pathExists(amPmVariant))) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && (await pathExists(nfdVariant))) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && (await pathExists(curlyVariant))) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) { + return nfdCurlyVariant; + } + + return resolved; +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index cd3b41ed..52e87b1d 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -12,8 +12,8 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveReadPath } from "./path-utils.ts"; -import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -71,11 +71,8 @@ function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): st return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); } -function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme): string { - const rawPath = str(args?.file_path ?? args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const invalidArg = invalidArgText(theme); - const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); +function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; } @@ -124,7 +121,7 @@ function getCompactReadClassification( const rawPath = str(args?.file_path ?? args?.path); if (!rawPath) return undefined; - const absolutePath = resolveReadPath(rawPath, cwd); + const absolutePath = resolveToCwd(rawPath, cwd); const fileName = basename(absolutePath); if (fileName === "SKILL.md") { return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; @@ -170,10 +167,10 @@ function formatReadResult( options: ToolRenderResultOptions, theme: Theme, showImages: boolean, - cwd: string, + _cwd: string, isError: boolean, ): string { - if (!options.expanded && !isError && getCompactReadClassification(args, cwd)) { + if (!options.expanded && !isError) { return ""; } @@ -187,7 +184,7 @@ function formatReadResult( const remaining = lines.length - maxLines; let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } const truncation = result.details?.truncation; @@ -223,7 +220,6 @@ export function createReadToolDefinition( _onUpdate?, ctx?, ) { - const absolutePath = resolveReadPath(path, cwd); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { if (signal?.aborted) { @@ -239,6 +235,8 @@ export function createReadToolDefinition( (async () => { try { + const absolutePath = await resolveReadPathAsync(path, cwd); + if (aborted) return; // Check if file exists and is readable. await ops.access(absolutePath); if (aborted) return; @@ -249,10 +247,9 @@ export function createReadToolDefinition( if (mimeType) { // Read image as binary. const buffer = await ops.readFile(absolutePath); - const base64 = buffer.toString("base64"); if (autoResizeImages) { // Resize image if needed before sending it back to the model. - const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const resized = await resizeImage(buffer, mimeType); if (!resized) { let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; @@ -272,7 +269,7 @@ export function createReadToolDefinition( if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [ { type: "text", text: textNote }, - { type: "image", data: base64, mimeType }, + { type: "image", data: buffer.toString("base64"), mimeType }, ]; } } else { @@ -344,7 +341,9 @@ export function createReadToolDefinition( const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined; text.setText( - classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme), + classification + ? formatCompactReadCall(classification, args, theme) + : formatReadCall(args, theme, context.cwd), ); return text; }, diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 1614ed64..48a5b0af 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,7 +1,10 @@ import * as os from "node:os"; +import { pathToFileURL } from "node:url"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; -import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; +import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../utils/ansi.ts"; +import { resolvePath } from "../../utils/paths.ts"; import { sanitizeBinaryOutput } from "../../utils/shell.ts"; export function shortenPath(path: unknown): string { @@ -13,6 +16,12 @@ export function shortenPath(path: unknown): string { return path; } +export function linkPath(styledText: string, rawPath: string, cwd: string): string { + if (!getCapabilities().hyperlinks) return styledText; + const absolutePath = resolvePath(rawPath, cwd); + return hyperlink(styledText, pathToFileURL(absolutePath).href); +} + export function str(value: unknown): string | null { if (typeof value === "string") return value; if (value == null) return ""; @@ -59,6 +68,18 @@ export type ToolRenderResultLike = { details: TDetails; }; -export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string { +export function invalidArgText(theme: Theme): string { return theme.fg("error", "[invalid arg]"); } + +export function renderToolPath( + rawPath: string | null, + theme: Theme, + cwd: string, + options?: { emptyFallback?: string }, +): string { + if (rawPath === null) return invalidArgText(theme); + const value = rawPath || options?.emptyFallback; + if (!value) return theme.fg("toolOutput", "..."); + return linkPath(theme.fg("accent", shortenPath(value)), value, cwd); +} diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 5c07848e..12668e61 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -4,11 +4,11 @@ import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; -import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.ts"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; -import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; const writeSchema = Type.Object({ @@ -131,14 +131,14 @@ function trimTrailingEmptyLines(lines: string[]): string[] { function formatWriteCall( args: { path?: string; file_path?: string; content?: string } | undefined, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, cache: WriteHighlightCache | undefined, + cwd: string, ): string { const rawPath = str(args?.file_path ?? args?.path); const fileContent = str(args?.content); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const invalidArg = invalidArgText(theme); - let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`; + const pathDisplay = renderToolPath(rawPath, theme, cwd); + let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`; if (fileContent === null) { text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; @@ -154,7 +154,7 @@ function formatWriteCall( const remaining = lines.length - maxLines; text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } @@ -163,7 +163,7 @@ function formatWriteCall( function formatWriteResult( result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): string | undefined { if (!result.isError) { return undefined; @@ -200,44 +200,29 @@ export function createWriteToolDefinition( ) { const absolutePath = resolveToCwd(path, cwd); const dir = dirname(absolutePath); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>( - (resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } - let aborted = false; - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - (async () => { - try { - // Create parent directories if needed. - await ops.mkdir(dir); - if (aborted) return; - // Write the file contents. - await ops.writeFile(absolutePath, content); - if (aborted) return; - signal?.removeEventListener("abort", onAbort); - resolve({ - content: [ - { type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }, - ], - details: undefined, - }); - } catch (error: any) { - signal?.removeEventListener("abort", onAbort); - if (!aborted) reject(error); - } - })(); - }, - ), - ); + return withFileMutationQueue(absolutePath, async () => { + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. + const throwIfAborted = (): void => { + if (signal?.aborted) throw new Error("Operation aborted"); + }; + + throwIfAborted(); + // Create parent directories if needed. + await ops.mkdir(dir); + throwIfAborted(); + + // Write the file contents. + await ops.writeFile(absolutePath, content); + throwIfAborted(); + + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }; + }); }, renderCall(args, theme, context) { const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined; @@ -258,6 +243,7 @@ export function createWriteToolDefinition( { expanded: context.expanded, isPartial: context.isPartial }, theme, component.cache, + context.cwd, ), ); return component; diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts new file mode 100644 index 00000000..9c494b47 --- /dev/null +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -0,0 +1,244 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; + +export type ProjectTrustDecision = boolean | null; + +export interface ProjectTrustStoreEntry { + path: string; + decision: boolean; +} + +export interface ProjectTrustUpdate { + path: string; + decision: ProjectTrustDecision; +} + +export interface ProjectTrustOption { + label: string; + trusted: boolean; + updates: ProjectTrustUpdate[]; + savedPath?: string; +} + +type TrustFile = Record; + +const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ + "settings.json", + "extensions", + "skills", + "prompts", + "themes", + "SYSTEM.md", + "APPEND_SYSTEM.md", +] as const; + +function normalizeCwd(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { + let currentDir = normalizeCwd(cwd); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) { + return { path: currentDir, decision: value }; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getProjectTrustParentPath(cwd: string): string | undefined { + const trustPath = normalizeCwd(cwd); + const parentDir = dirname(trustPath); + return parentDir === trustPath ? undefined : parentDir; +} + +export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { + const trustPath = normalizeCwd(cwd); + const trustOptions: ProjectTrustOption[] = [ + { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, + ]; + const parentPath = getProjectTrustParentPath(cwd); + if (parentPath !== undefined) { + trustOptions.push({ + label: `Trust parent folder (${parentPath})`, + trusted: true, + updates: [ + { path: parentPath, decision: true }, + { path: trustPath, decision: null }, + ], + savedPath: parentPath, + }); + } + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] }); + } + trustOptions.push({ + label: "Do not trust", + trusted: false, + updates: [{ path: trustPath, decision: false }], + savedPath: trustPath, + }); + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] }); + } + return trustOptions; +} + +function readTrustFile(path: string): TrustFile { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read trust store ${path}: ${message}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${path}: expected an object`); + } + + const data: TrustFile = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value !== true && value !== false && value !== null) { + throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`); + } + data[key] = value; + } + return data; +} + +function writeTrustFile(path: string, data: TrustFile): void { + const sorted: TrustFile = {}; + for (const key of Object.keys(data).sort()) { + const value = data[key]; + if (value === true || value === false || value === null) { + sorted[key] = value; + } + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +function acquireTrustLockSync(path: string): () => void { + const trustDir = dirname(path); + mkdirSync(trustDir, { recursive: true }); + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing trust store callers to async. + } + } + } + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to acquire trust store lock"); +} + +function withTrustFileLock(path: string, fn: () => T): T { + const release = acquireTrustLockSync(path); + try { + return fn(); + } finally { + release(); + } +} + +/** + * Returns true when cwd has project-local resources that must be gated by + * project trust: trust-requiring entries under cwd/.pi, or .agents/skills in + * cwd or one of its ancestors. Returns false when no such project resources + * exist. The user/global ~/.agents/skills directory is always treated as a + * trusted user resource and is ignored here, even when cwd is $HOME. + */ +export function hasTrustRequiringProjectResources(cwd: string): boolean { + const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); + const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); + let currentDir = canonicalizePath(resolvePath(cwd)); + + const configDir = join(currentDir, CONFIG_DIR_NAME); + if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { + return true; + } + + while (true) { + const agentsSkillsDir = join(currentDir, ".agents", "skills"); + if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) { + return true; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return false; + } + currentDir = parentDir; + } +} + +export class ProjectTrustStore { + private trustPath: string; + + constructor(agentDir: string) { + this.trustPath = join(resolvePath(agentDir), "trust.json"); + } + + get(cwd: string): ProjectTrustDecision { + return this.getEntry(cwd)?.decision ?? null; + } + + getEntry(cwd: string): ProjectTrustStoreEntry | null { + return withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + return findNearestTrustEntry(data, cwd); + }); + } + + set(cwd: string, decision: ProjectTrustDecision): void { + this.setMany([{ path: cwd, decision }]); + } + + setMany(decisions: ProjectTrustUpdate[]): void { + withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + for (const { path, decision } of decisions) { + const key = normalizeCwd(path); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } + } + writeTrustFile(this.trustPath, data); + }); + } +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8627bdcd..5830ecda 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -1,7 +1,17 @@ // Core session management +export { type Args, parseArgs } from "./cli/args.ts"; + // Config paths -export { getAgentDir, VERSION } from "./config.ts"; +export { + CONFIG_DIR_NAME, + getAgentDir, + getDocsPath, + getExamplesPath, + getPackageDir, + getReadmePath, + VERSION, +} from "./config.ts"; export { AgentSession, type AgentSessionConfig, @@ -96,6 +106,11 @@ export type { LsToolCallEvent, MessageRenderer, MessageRenderOptions, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventDecision, + ProjectTrustEventResult, + ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, @@ -213,10 +228,12 @@ export { } from "./core/session-manager.ts"; export { type CompactionSettings, + type DefaultProjectTrust, type ImageSettings, type PackageSource, type RetrySettings, SettingsManager, + type SettingsManagerCreateOptions, } from "./core/settings-manager.ts"; // Skills export { @@ -229,6 +246,7 @@ export { type SkillFrontmatter, } from "./core/skills.ts"; export { createSyntheticSourceInfo } from "./core/source-info.ts"; +export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts"; // Tools export { type BashOperations, @@ -279,6 +297,13 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; +export { + hasTrustRequiringProjectResources, + type ProjectTrustDecision, + ProjectTrustStore, + type ProjectTrustStoreEntry, + type ProjectTrustUpdate, +} from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage @@ -291,6 +316,8 @@ export { type RpcClientOptions, type RpcCommand, type RpcEventListener, + type RpcExtensionUIRequest, + type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, @@ -349,6 +376,7 @@ export { // Clipboard utilities export { copyToClipboard } from "./utils/clipboard.ts"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; +export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; // Shell utilities export { getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5395e045..f66040bb 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,13 +7,14 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; -import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; +import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -25,11 +26,11 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts"; -import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; -import { KeybindingsManager } from "./core/keybindings.ts"; +import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import { formatMissingSessionCwdPrompt, @@ -37,12 +38,12 @@ import { MissingSessionCwdError, type SessionCwdIssue, } from "./core/session-cwd.ts"; -import { SessionManager } from "./core/session-manager.ts"; +import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; -import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; @@ -94,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } -type AppMode = "interactive" | "print" | "json" | "rpc"; - -function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode { +function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { if (parsed.mode === "rpc") { return "rpc"; } if (parsed.mode === "json") { return "json"; } - if (parsed.print || !stdinIsTTY) { + if (parsed.print || !stdinIsTTY || !stdoutIsTTY) { return "print"; } return "interactive"; @@ -113,6 +112,10 @@ function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } +function isPlainRuntimeMetadataCommand(parsed: Args): boolean { + return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); +} + async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -145,6 +148,16 @@ type ResolvedSession = * Resolve a session argument to a file path. * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ +async function findLocalSessionByExactId( + sessionId: string, + cwd: string, + sessionDir?: string, +): Promise<{ type: "local"; path: string } | undefined> { + const localSessions = await SessionManager.list(cwd, sessionDir); + const localMatch = localSessions.find((s) => s.id === sessionId); + return localMatch ? { type: "local", path: localMatch.path } : undefined; +} + async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { @@ -153,19 +166,20 @@ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: // Try to match as session ID in current project first const localSessions = await SessionManager.list(cwd, sessionDir); - const localMatches = localSessions.filter((s) => s.id.startsWith(sessionArg)); + const localMatch = + localSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg)); - if (localMatches.length >= 1) { - return { type: "local", path: localMatches[0].path }; + if (localMatch) { + return { type: "local", path: localMatch.path }; } // Try global search across all projects - const allSessions = await SessionManager.listAll(); - const globalMatches = allSessions.filter((s) => s.id.startsWith(sessionArg)); + const allSessions = await SessionManager.listAll(sessionDir); + const globalMatch = + allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); - if (globalMatches.length >= 1) { - const match = globalMatches[0]; - return { type: "global", path: match.path, cwd: match.cwd }; + if (globalMatch) { + return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd }; } // Not found anywhere @@ -202,9 +216,33 @@ function validateForkFlags(parsed: Args): void { } } -function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string): SessionManager { +function validateSessionIdFlags(parsed: Args): void { + if (parsed.sessionId === undefined) return; + + const conflictingFlags = [ + parsed.session ? "--session" : undefined, + parsed.continue ? "--continue" : undefined, + parsed.resume ? "--resume" : undefined, + parsed.noSession ? "--no-session" : undefined, + ].filter((flag): flag is string => flag !== undefined); + + if (conflictingFlags.length > 0) { + console.error(chalk.red(`Error: --session-id cannot be combined with ${conflictingFlags.join(", ")}`)); + process.exit(1); + } + try { - return SessionManager.forkFrom(sourcePath, cwd, sessionDir); + assertValidSessionId(parsed.sessionId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } +} + +function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, sessionId?: string): SessionManager { + try { + return SessionManager.forkFrom(sourcePath, cwd, sessionDir, { id: sessionId }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.error(chalk.red(`Error: ${message}`)); @@ -218,18 +256,26 @@ async function createSessionManager( sessionDir: string | undefined, settingsManager: SettingsManager, ): Promise { - if (parsed.noSession) { - return SessionManager.inMemory(); + if (parsed.noSession || parsed.help || parsed.listModels !== undefined) { + return SessionManager.inMemory(cwd); } if (parsed.fork) { + if (parsed.sessionId) { + const existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingTarget) { + console.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`)); + process.exit(1); + } + } + const resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir); switch (resolved.type) { case "path": case "local": case "global": - return forkSessionOrExit(resolved.path, cwd, sessionDir); + return forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId); case "not_found": console.error(chalk.red(`No session found matching '${resolved.arg}'`)); @@ -266,7 +312,7 @@ async function createSessionManager( try { const selectedPath = await selectSession( (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), - SessionManager.listAll, + (onProgress) => SessionManager.listAll(sessionDir, onProgress), ); if (!selectedPath) { console.log(chalk.dim("No session selected")); @@ -282,7 +328,14 @@ async function createSessionManager( return SessionManager.continueRecent(cwd, sessionDir); } - return SessionManager.create(cwd, sessionDir); + if (parsed.sessionId) { + const existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingSession) { + return SessionManager.open(existingSession.path, sessionDir); + } + } + + return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId }); } function buildSessionOptions( @@ -307,6 +360,7 @@ function buildSessionOptions( const resolved = resolveCliModel({ cliProvider: parsed.provider, cliModel: parsed.model, + cliThinking: parsed.thinking, modelRegistry, }); if (resolved.warning) { @@ -375,6 +429,9 @@ function buildSessionOptions( if (parsed.tools) { options.tools = [...parsed.tools]; } + if (parsed.excludeTools) { + options.excludeTools = [...parsed.excludeTools]; + } return { options, cliThinkingFromModel, diagnostics }; } @@ -387,34 +444,10 @@ async function promptForMissingSessionCwd( issue: SessionCwdIssue, settingsManager: SettingsManager, ): Promise { - initTheme(settingsManager.getTheme()); - setKeybindings(KeybindingsManager.create()); - - return new Promise((resolve) => { - const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); - ui.setClearOnShrink(settingsManager.getClearOnShrink()); - - let settled = false; - const finish = (result: string | undefined) => { - if (settled) { - return; - } - settled = true; - ui.stop(); - resolve(result); - }; - - const selector = new ExtensionSelectorComponent( - formatMissingSessionCwdPrompt(issue), - ["Continue", "Cancel"], - (option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), - () => finish(undefined), - { tui: ui }, - ); - ui.addChild(selector); - ui.setFocus(selector); - ui.start(); - }); + return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [ + { label: "Continue", value: issue.fallbackCwd }, + { label: "Cancel", value: undefined }, + ]); } export interface MainOptions { @@ -433,11 +466,26 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } - if (await handlePackageCommand(args)) { + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(); + + if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { + const exitCode = process.exitCode ?? 0; + if (process.platform === "win32" && exitCode === 0 && args[0] === "update") { + // We normally prefer process.exit(0) for package commands so bad extensions cannot keep + // one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0) + // runs during teardown; let successful `pi update` drain naturally instead. + // https://github.com/nodejs/node/issues/56645 + return; + } + process.exit(exitCode); return; } - if (await handleConfigCommand(args)) { + if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) { return; } @@ -452,11 +500,6 @@ export async function main(args: string[], options?: MainOptions) { } } time("parseArgs"); - let appMode = resolveAppMode(parsed, process.stdin.isTTY); - const shouldTakeOverStdout = appMode !== "interactive"; - if (shouldTakeOverStdout) { - takeOverStdout(); - } if (parsed.version) { console.log(VERSION); @@ -477,22 +520,34 @@ export async function main(args: string[], options?: MainOptions) { process.exit(0); } + let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); + const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed); + if (shouldTakeOverStdout) { + takeOverStdout(); + } + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); } validateForkFlags(parsed); + validateSessionIdFlags(parsed); // Run migrations (pass cwd for project-local migrations) - const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); + const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd); time("runMigrations"); - const cwd = process.cwd(); - const agentDir = getAgentDir(); const startupSettingsManager = SettingsManager.create(cwd, agentDir); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + // Experimental first-time setup: theme choice and analytics opt-in. + // Runs before any runtime services are created so the chosen settings apply everywhere. + if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) { + await showFirstTimeSetup(startupSettingsManager); + time("firstTimeSetup"); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after @@ -517,8 +572,25 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } } + if (parsed.name !== undefined) { + const name = parsed.name.trim(); + if (!name) { + console.error(chalk.red("Error: --name requires a non-empty value")); + process.exit(1); + } + sessionManager.appendSessionInfo(name); + } time("createSessionManager"); + const trustStore = new ProjectTrustStore(agentDir); + const sessionCwd = sessionManager.getCwd(); + const autoTrustOnReloadCwd = + parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd) + ? sessionCwd + : undefined; + const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; + const projectTrustByCwd = new Map(); + const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); @@ -529,12 +601,50 @@ export async function main(args: string[], options?: MainOptions) { agentDir, sessionManager, sessionStartEvent, + projectTrustContext, }) => { + const isInitialRuntime = sessionStartEvent === undefined; + const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = []; + const cachedProjectTrust = projectTrustByCwd.get(cwd); + const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd); + const shouldResolveProjectTrust = + parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources; + const projectTrusted = shouldResolveProjectTrust + ? false + : (cachedProjectTrust ?? + parsed.projectTrustOverride ?? + (!hasTrustRequiringResources || trustStore.get(cwd) === true)); + const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, agentDir, authStorage, + settingsManager: runtimeSettingsManager, extensionFlagValues: parsed.unknownFlags, + resourceLoaderReloadOptions: shouldResolveProjectTrust + ? { + resolveProjectTrust: async ({ extensionsResult }) => { + const trusted = await resolveProjectTrusted({ + cwd, + trustStore, + trustOverride: parsed.projectTrustOverride, + defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(), + extensionsResult, + projectTrustContext: + projectTrustContext ?? + createProjectTrustContext({ + cwd, + mode: isInitialRuntime ? trustPromptMode : appMode, + settingsManager: startupSettingsManager, + hasUI: isInitialRuntime && trustPromptMode === "interactive", + }), + onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }), + }); + projectTrustByCwd.set(cwd, trusted); + return trusted; + }, + } + : undefined, resourceLoaderOptions: { additionalExtensionPaths: resolvedExtensionPaths, additionalSkillPaths: resolvedSkillPaths, @@ -552,6 +662,7 @@ export async function main(args: string[], options?: MainOptions) { }); const { settingsManager, modelRegistry, resourceLoader } = services; const diagnostics: AgentSessionRuntimeDiagnostic[] = [ + ...projectTrustDiagnostics, ...services.diagnostics, ...collectSettingsDiagnostics(settingsManager, "runtime creation"), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ @@ -595,6 +706,7 @@ export async function main(args: string[], options?: MainOptions) { thinkingLevel: sessionOptions.thinkingLevel, scopedModels: sessionOptions.scopedModels, tools: sessionOptions.tools, + excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, }); @@ -615,8 +727,10 @@ export async function main(args: string[], options?: MainOptions) { agentDir, sessionManager, }); + time("createAgentSessionRuntime"); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRegistry, resourceLoader } = services; + applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { @@ -682,6 +796,7 @@ export async function main(args: string[], options?: MainOptions) { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, modelFallbackMessage, + autoTrustOnReloadCwd, initialMessage, initialImages, initialMessages: parsed.messages, diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 02cab782..732aee55 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -6,4 +6,10 @@ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/inte export { type PrintModeOptions, runPrintMode } from "./print-mode.ts"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts"; export { runRpcMode } from "./rpc/rpc-mode.ts"; -export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.ts"; +export type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc/rpc-types.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index e53f5a5e..b46a9d0b 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -176,10 +176,12 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { if (this.expanded) { - statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`); + statusParts.push( + `${theme.fg("muted", "(")}${keyHint("app.tools.expand", "to collapse")}${theme.fg("muted", ")")}`, + ); } else { statusParts.push( - `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`, + `${theme.fg("muted", `... ${hiddenLineCount} more lines (`)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`, ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index d4f16726..7c46841d 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -73,7 +73,7 @@ function formatBaseDir(baseDir: string): string { return displayPath.endsWith("/") ? displayPath : `${displayPath}/`; } -function getGroupLabel(metadata: PathMetadata): string { +function getGroupLabel(metadata: PathMetadata, agentDir: string): string { if (metadata.origin === "package") { return `${metadata.source} (${metadata.scope})`; } @@ -84,12 +84,12 @@ function getGroupLabel(metadata: PathMetadata): string { ? `User (${formatBaseDir(metadata.baseDir)})` : `Project (${formatBaseDir(metadata.baseDir)})`; } - return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)"; + return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`; } return metadata.scope === "user" ? "User settings" : "Project settings"; } -function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { +function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] { const groupMap = new Map(); const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => { @@ -100,7 +100,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { key: groupKey, - label: getGroupLabel(metadata), + label: getGroupLabel(metadata, agentDir), scope: metadata.scope, origin: metadata.origin, source: metadata.source, @@ -567,7 +567,7 @@ class ResourceList implements Component, Focusable { private getResourcePattern(item: ResourceItem): string { const scope = item.metadata.scope as "user" | "project"; - const baseDir = this.getTopLevelBaseDir(scope); + const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope); return relative(baseDir, item.path); } @@ -601,7 +601,7 @@ export class ConfigSelectorComponent extends Container implements Focusable { ) { super(); - const groups = buildGroups(resolvedPaths); + const groups = buildGroups(resolvedPaths, agentDir); // Add header this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts new file mode 100644 index 00000000..de5f76e0 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -0,0 +1,145 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../../config.ts"; +import { type TerminalTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface FirstTimeSetupResult { + theme: TerminalTheme; + shareAnalytics: boolean; +} + +export interface FirstTimeSetupOptions { + detectedTheme: TerminalTheme; + onThemePreview: (themeName: TerminalTheme) => void; + onSubmit: (result: FirstTimeSetupResult) => void; + onCancel: () => void; +} + +const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +]; + +const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [ + { value: true, label: "Share anonymous usage data" }, + { value: false, label: "Don't share" }, +]; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +/** First-time setup dialog: theme choice and analytics opt-in. */ +export class FirstTimeSetupComponent extends Container { + private step: "theme" | "analytics" = "theme"; + private themeIndex: number; + private analyticsIndex = 0; + private readonly options: FirstTimeSetupOptions; + + constructor(options: FirstTimeSetupOptions) { + super(); + this.options = options; + this.themeIndex = Math.max( + 0, + THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme), + ); + this.update(); + } + + // Rebuild the whole dialog on every change so theme previews recolor all text. + private update(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0), + ); + this.addChild(new Spacer(1)); + + if (this.step === "theme") { + this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0)); + this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0)); + this.addChild(new Spacer(1)); + this.addOptionList( + THEME_OPTIONS.map((option) => option.label), + this.themeIndex, + ); + } else { + this.addChild(new Text(theme.fg("text", "Opt-in to anonymous usage data sharing?"), 1, 0)); + this.addChild( + new Text( + theme.fg( + "muted", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. This helps us to better debug, reproduce, and resolve issues\nand bugs within Pi. You can observe what is shared using /privacy and make\nchanges anytime in settings.json.", + ), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addOptionList( + ANALYTICS_OPTIONS.map((option) => option.label), + this.analyticsIndex, + ); + } + + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") + + " " + + keyHint("tui.select.cancel", "skip setup"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private addOptionList(labels: string[], selectedIndex: number): void { + for (let i = 0; i < labels.length; i++) { + const isSelected = i === selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + this.addChild(new Text(`${prefix}${label}`, 1, 0)); + } + } + + private moveSelection(delta: number): void { + if (this.step === "theme") { + const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta)); + if (next !== this.themeIndex) { + this.themeIndex = next; + this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value); + } + } else { + this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta)); + } + this.update(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.moveSelection(-1); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.moveSelection(1); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + if (this.step === "theme") { + this.step = "analytics"; + this.update(); + } else { + this.options.onSubmit({ + theme: THEME_OPTIONS[this.themeIndex].value, + shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value, + }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.options.onCancel(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index a48898a5..aa537127 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,5 +1,7 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; +import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; import { theme } from "../theme/theme.ts"; @@ -26,6 +28,20 @@ function formatTokens(count: number): string { return `${Math.round(count / 1000000)}M`; } +export function formatCwdForFooter(cwd: string, home: string | undefined): string { + if (!home) return cwd; + + const resolvedCwd = resolve(cwd); + const resolvedHome = resolve(home); + const relativeToHome = relative(resolvedHome, resolvedCwd); + const isInsideHome = + relativeToHome === "" || + (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome)); + + if (!isInsideHome) return cwd; + return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; +} + /** * Footer component that shows pwd, token stats, and context usage. * Computes token/context stats from session, gets git branch and extension statuses from provider. @@ -73,6 +89,7 @@ export class FooterComponent implements Component { let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; + let latestCacheHitRate: number | undefined; for (const entry of this.session.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { @@ -81,6 +98,11 @@ export class FooterComponent implements Component { totalCacheRead += entry.message.usage.cacheRead; totalCacheWrite += entry.message.usage.cacheWrite; totalCost += entry.message.usage.cost.total; + + const latestPromptTokens = + entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; + latestCacheHitRate = + latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; } } @@ -92,11 +114,7 @@ export class FooterComponent implements Component { const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; // Replace home directory with ~ - let pwd = this.session.sessionManager.getCwd(); - const home = process.env.HOME || process.env.USERPROFILE; - if (home && pwd.startsWith(home)) { - pwd = `~${pwd.slice(home.length)}`; - } + let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE); // Add git branch if available const branch = this.footerData.getGitBranch(); @@ -116,6 +134,9 @@ export class FooterComponent implements Component { if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) { + statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); + } // Show cost with "(sub)" indicator if using OAuth subscription const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; @@ -139,6 +160,9 @@ export class FooterComponent implements Component { contextPercentStr = contextPercentDisplay; } statsParts.push(contextPercentStr); + if (areExperimentalFeaturesEnabled()) { + statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`); + } let statsLeft = statsParts.join(" "); diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index d9b5d773..38c2b987 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -13,6 +13,11 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { + FirstTimeSetupComponent, + type FirstTimeSetupOptions, + type FirstTimeSetupResult, +} from "./first-time-setup.ts"; export { FooterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; @@ -27,6 +32,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts"; export { ThinkingSelectorComponent } from "./thinking-selector.ts"; export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; export { TreeSelectorComponent } from "./tree-selector.ts"; +export { TrustSelectorComponent } from "./trust-selector.ts"; export { UserMessageComponent } from "./user-message.ts"; export { UserMessageSelectorComponent } from "./user-message-selector.ts"; export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 80560f14..3fc4b516 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,6 +1,6 @@ -import { getOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import { exec } from "child_process"; +import { openBrowser } from "../../../utils/open-browser.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable { this.input = new Input(); this.input.onSubmit = () => { if (this.inputResolver) { - this.inputResolver(this.input.getValue()); + const value = this.input.getValue(); + this.replaceInputWithSubmittedText(value); + this.inputResolver(value); this.inputResolver = undefined; this.inputRejecter = undefined; } @@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable { return this.abortController.signal; } + private replaceInputWithSubmittedText(value: string): void { + this.contentContainer.children = this.contentContainer.children.map((child) => + child === this.input ? new Text(`> ${value}`, 0, 0) : child, + ); + } + private cancel(): void { this.abortController.abort(); if (this.inputRejecter) { @@ -101,9 +109,24 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - // Try to open browser - const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - exec(`${openCmd} "${url}"`); + openBrowser(url); + this.tui.requestRender(); + } + + /** + * Called by onDeviceCode callback - show URL and user code. + */ + showDeviceCode(info: OAuthDeviceCodeInfo): void { + this.contentContainer.clear(); + this.contentContainer.addChild(new Spacer(1)); + const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); + + const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; + const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); this.tui.requestRender(); } @@ -112,6 +135,7 @@ export class LoginDialogComponent extends Container implements Focusable { * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { + this.input.setValue(""); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index b9f5ec77..32711929 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,6 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRegistry } from "../../../core/model-registry.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -217,10 +218,8 @@ export class ModelSelectorComponent extends Container implements Focusable { private filterModels(query: string): void { this.filteredModels = query - ? fuzzyFilter( - this.activeModels, - query, - ({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`, + ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => + getModelSelectorSearchText({ id, provider, name: model.name }), ) : this.activeModels; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 06ce9169..772e3af0 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -10,6 +10,7 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; +import { getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -182,7 +183,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl private refresh(): void { const query = this.searchInput.getValue(); const items = this.buildItems(); - this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items; + this.filteredItems = query + ? fuzzyFilter(items, query, (i) => + getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }), + ) + : items; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); this.updateList(); this.footerText.setText(this.getFooterText()); diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 74141e5e..a92f0762 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -694,7 +694,6 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessions: SessionInfo[] | null = null; private currentSessionsLoader: SessionsLoader; private allSessionsLoader: SessionsLoader; - private onCancel: () => void; private requestRender: () => void; private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; private currentLoading = false; @@ -751,7 +750,6 @@ export class SessionSelectorComponent extends Container implements Focusable { this.keybindings = options?.keybindings ?? KeybindingsManager.create(); this.currentSessionsLoader = currentSessionsLoader; this.allSessionsLoader = allSessionsLoader; - this.onCancel = onCancel; this.requestRender = requestRender; this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender); const renameSession = options?.renameSession; @@ -948,10 +946,6 @@ export class SessionSelectorComponent extends Container implements Focusable { this.header.setLoading(false); this.sessionList.setSessions(sessions, showCwd); this.requestRender(); - - if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { - this.onCancel(); - } } catch (err) { if (scope === "current") { this.currentLoading = false; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028..7cc92614 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,6 +1,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Transport } from "@earendil-works/pi-ai"; import { + type Component, Container, getCapabilities, type SelectItem, @@ -12,8 +13,14 @@ import { Text, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; -import type { WarningSettings } from "../../../core/settings-manager.ts"; -import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; +import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; +import { + getSelectListTheme, + getSettingsListTheme, + parseAutoThemeSetting, + type TerminalTheme, + theme, +} from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -31,6 +38,16 @@ const THINKING_DESCRIPTIONS: Record = { xhigh: "Maximum reasoning (~32k tokens)", }; +const DEFAULT_PROJECT_TRUST_LABELS: Record = { + ask: "Ask", + always: "Always trust", + never: "Never trust", +}; + +const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( + Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]), +); + export interface SettingsConfig { autoCompact: boolean; showImages: boolean; @@ -45,6 +62,7 @@ export interface SettingsConfig { thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; + terminalTheme: TerminalTheme; availableThemes: string[]; hideThinkingBlock: boolean; collapseChangelog: boolean; @@ -55,6 +73,7 @@ export interface SettingsConfig { editorPaddingX: number; autocompleteMaxVisible: number; quietStartup: boolean; + defaultProjectTrust: DefaultProjectTrust; clearOnShrink: boolean; showTerminalProgress: boolean; warnings: WarningSettings; @@ -83,6 +102,7 @@ export interface SettingsCallbacks { onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; + onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onWarningsChange: (warnings: WarningSettings) => void; @@ -198,6 +218,249 @@ class SelectSubmenu extends Container { } } +function themeItems(availableThemes: string[]): SelectItem[] { + return availableThemes.map((name) => ({ value: name, label: name })); +} + +const AUTOMATIC_THEME_VALUE = "/"; + +function singleModeThemeItems(availableThemes: string[]): SelectItem[] { + return [ + { + value: AUTOMATIC_THEME_VALUE, + label: "Automatic", + description: "Use separate themes for light and dark terminal appearance", + }, + ...themeItems(availableThemes), + ]; +} + +function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string { + if (preferred && availableThemes.includes(preferred)) return preferred; + if (availableThemes.includes(fallback)) return fallback; + return availableThemes[0] ?? fallback; +} + +function defaultAutomaticThemes( + currentThemeSetting: string, + availableThemes: string[], +): { lightTheme: string; darkTheme: string } { + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + if (autoTheme) return autoTheme; + + const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark"); + return { lightTheme: themeName, darkTheme: themeName }; +} + +class ThemeSubmenu extends Container { + private inputComponent: Component | undefined; + private readonly callbacks: SettingsCallbacks; + private readonly availableThemes: string[]; + private readonly terminalTheme: TerminalTheme; + private readonly onDone: (selectedValue?: string) => void; + private readonly originalThemeSetting: string; + private mode: "single" | "automatic"; + private singleTheme: string; + private lightTheme: string; + private darkTheme: string; + + constructor( + currentThemeSetting: string, + terminalTheme: TerminalTheme, + availableThemes: string[], + callbacks: SettingsCallbacks, + onDone: (selectedValue?: string) => void, + ) { + super(); + this.callbacks = callbacks; + this.availableThemes = availableThemes; + this.terminalTheme = terminalTheme; + this.onDone = onDone; + this.originalThemeSetting = currentThemeSetting; + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes); + const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + this.mode = autoTheme ? "automatic" : "single"; + this.lightTheme = automaticThemes.lightTheme; + this.darkTheme = automaticThemes.darkTheme; + this.singleTheme = preferredTheme( + availableThemes, + fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined), + "dark", + ); + + if (this.mode === "automatic") { + this.showAutomaticMenu(); + } else { + this.showSingleMenu(); + } + } + + handleInput(data: string): void { + this.inputComponent?.handleInput?.(data); + } + + private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void { + this.clear(); + this.addChild(renderComponent); + this.inputComponent = inputComponent; + } + + private showSingleMenu(): void { + this.mode = "single"; + const menu = new SelectSubmenu( + "Theme", + "Select a theme, or choose Automatic to follow terminal appearance.", + singleModeThemeItems(this.availableThemes), + this.singleTheme, + (value) => { + if (value === AUTOMATIC_THEME_VALUE) { + this.mode = "automatic"; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + this.showAutomaticMenu(); + return; + } + + this.singleTheme = value; + this.apply(value); + }, + () => this.cancel(), + (value) => { + this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value); + }, + ); + this.setContent(menu); + } + + private showAutomaticMenu(): void { + this.mode = "automatic"; + const content = new Container(); + content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0)); + content.addChild(new Spacer(1)); + content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0)); + content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0)); + content.addChild(new Spacer(1)); + + const items: SettingItem[] = [ + { + id: "light-theme", + label: "Light theme", + description: "Theme to use in automatic mode when the terminal is light", + currentValue: this.lightTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Light Theme", + "Select the theme to use for light terminal appearance", + currentValue, + done, + (value) => { + this.lightTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "dark-theme", + label: "Dark theme", + description: "Theme to use in automatic mode when the terminal is dark", + currentValue: this.darkTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Dark Theme", + "Select the theme to use for dark terminal appearance", + currentValue, + done, + (value) => { + this.darkTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "apply", + label: "Apply", + description: "Save and go back", + currentValue: "save and go back", + values: ["save and go back"], + }, + { + id: "single-mode", + label: "Change mode", + description: "Switch to one theme for light and dark", + currentValue: "switch to single theme", + values: ["switch to single theme"], + }, + ]; + + const settingsList = new SettingsList( + items, + Math.min(items.length, 10), + getSettingsListTheme(), + (id) => { + switch (id) { + case "single-mode": + this.mode = "single"; + this.singleTheme = this.getActiveAutomaticTheme(); + this.callbacks.onThemePreview?.(this.singleTheme); + this.showSingleMenu(); + break; + case "apply": + this.apply(this.getAutomaticThemeSetting()); + break; + } + }, + () => this.cancel(), + ); + content.addChild(settingsList); + this.setContent(content, settingsList); + } + + private createThemeSelect( + title: string, + description: string, + currentValue: string, + done: (selectedValue?: string) => void, + onSelect: (value: string) => void, + ): SelectSubmenu { + return new SelectSubmenu( + title, + description, + themeItems(this.availableThemes), + currentValue, + onSelect, + () => { + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(); + }, + (value) => this.callbacks.onThemePreview?.(value), + ); + } + + private getThemeSetting(): string { + return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme; + } + + private getActiveAutomaticTheme(): string { + return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme; + } + + private getAutomaticThemeSetting(): string { + return `${this.lightTheme}/${this.darkTheme}`; + } + + private apply(themeSetting: string): void { + this.onDone(themeSetting); + } + + private cancel(): void { + this.callbacks.onThemePreview?.(this.originalThemeSetting); + this.onDone(); + } +} + /** * Main settings selector component. */ @@ -277,6 +540,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.enableInstallTelemetry ? "true" : "false", values: ["true", "false"], }, + { + id: "default-project-trust", + label: "Default project trust", + description: "Fallback behavior when no extension or saved trust decision decides project trust", + currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust], + values: Object.values(DEFAULT_PROJECT_TRUST_LABELS), + }, { id: "double-escape-action", label: "Double-escape action", @@ -334,28 +604,7 @@ export class SettingsSelectorComponent extends Container { description: "Color theme for the interface", currentValue: config.currentTheme, submenu: (currentValue, done) => - new SelectSubmenu( - "Theme", - "Select color theme", - config.availableThemes.map((t) => ({ - value: t, - label: t, - })), - currentValue, - (value) => { - callbacks.onThemeChange(value); - done(value); - }, - () => { - // Restore original theme on cancel - callbacks.onThemePreview?.(currentValue); - done(); - }, - (value) => { - // Preview theme on selection change - callbacks.onThemePreview?.(value); - }, - ), + new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done), }, ]; @@ -512,6 +761,13 @@ export class SettingsSelectorComponent extends Container { case "install-telemetry": callbacks.onEnableInstallTelemetryChange(newValue === "true"); break; + case "default-project-trust": { + const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue); + if (defaultProjectTrust) { + callbacks.onDefaultProjectTrustChange(defaultProjectTrust); + } + break; + } case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; @@ -535,6 +791,9 @@ export class SettingsSelectorComponent extends Container { case "terminal-progress": callbacks.onShowTerminalProgressChange(newValue === "true"); break; + case "theme": + callbacks.onThemeChange(newValue); + break; } }, callbacks.onCancel, diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b941dfc8..ad84f441 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -222,6 +222,31 @@ export class ToolExecutionComponent extends Container { if (this.hideComponent) { return []; } + + if (this.hasRendererDefinition() && this.getRenderShell() === "self") { + const contentLines = this.selfRenderContainer.render(width); + if (contentLines.length === 0 && this.imageComponents.length === 0) { + return []; + } + + const lines: string[] = []; + if (contentLines.length > 0) { + lines.push(""); + lines.push(...contentLines); + } + for (let i = 0; i < this.imageComponents.length; i++) { + const spacer = this.imageSpacers[i]; + if (spacer) { + lines.push(...spacer.render(width)); + } + const imageComponent = this.imageComponents[i]; + if (imageComponent) { + lines.push(...imageComponent.render(width)); + } + } + return lines; + } + return super.render(width); } diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 990e705d..8bcdc2fb 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -4,15 +4,18 @@ import { type Focusable, getKeybindings, Input, + type Keybinding, Spacer, + sliceByColumn, Text, - TruncatedText, truncateToWidth, + visibleWidth, + wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import type { SessionTreeNode } from "../../../core/session-manager.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; -import { keyHint, keyText } from "./keybinding-hints.ts"; +import { formatKeyText, keyHint } from "./keybinding-hints.ts"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -35,6 +38,59 @@ interface FlatNode { isVirtualRootChild: boolean; } +interface HorizontalViewportRow { + gutter: string; + body: string; + anchorCol: number; + bodyWidth: number; + isSelected: boolean; +} + +const TREE_GUTTER_WIDTH = 2; +const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4; +const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20; +const MIN_ANCHOR_CONTEXT_WIDTH = 2; +const MAX_ANCHOR_CONTEXT_WIDTH = 12; + +/** + * Render tree rows into a horizontally clipped viewport. + * + * The tree gutter is always kept visible. The row bodies are shifted left only + * when the selected row's anchor (the start of its entry text after tree + * indentation/markers) would otherwise be too far right to see useful content. + */ +function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] { + const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH); + const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0); + const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth); + const selectedRow = rows.find((row) => row.isSelected); + + // Only pan horizontally when needed to keep enough selected-row content visible after its anchor. + let horizontalScroll = 0; + if (selectedRow && maxHorizontalScroll > 0) { + const minVisibleAnchorContentWidth = Math.min( + MAX_VISIBLE_ANCHOR_CONTENT_WIDTH, + Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)), + ); + if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) { + const anchorContextWidth = Math.min( + MAX_ANCHOR_CONTEXT_WIDTH, + Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)), + ); + horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth); + } + } + + // Clip only the body; the fixed-width gutter remains visible as navigation context. + return rows.map((row) => { + const line = + horizontalScroll > 0 + ? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m` + : row.gutter + row.body; + return truncateToWidth(line, width, ""); + }); +} + /** Filter mode for tree display */ export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; @@ -617,6 +673,7 @@ class TreeList implements Component { ); const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + const renderedRows: HorizontalViewportRow[] = []; for (let i = startIndex; i < endIndex; i++) { const flatNode = this.filteredNodes[i]; const entry = flatNode.node.entry; @@ -680,14 +737,18 @@ class TreeList implements Component { ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - - let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content; + const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker; + const anchorCol = visibleWidth(prefixPart); + let gutter = cursor; + let body = prefixPart + label + labelTimestamp + content; if (isSelected) { - line = theme.bg("selectedBg", line); + gutter = theme.bg("selectedBg", gutter); + body = theme.bg("selectedBg", body); } - lines.push(truncateToWidth(line, width)); + renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected }); } + lines.push(...renderHorizontalViewport(renderedRows, width)); lines.push( truncateToWidth( theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), @@ -1075,6 +1136,98 @@ class SearchLine implements Component { handleInput(_keyData: string): void {} } +/** Component that renders tree help as semantic rows with chunk-aware wrapping */ +class TreeHelp implements Component { + invalidate(): void {} + + render(width: number): string[] { + const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => { + const text = formatHelpKeys(keys); + if (!text) return label; + return labelFirst ? `${label} ${text}` : `${text} ${label}`; + }); + + const availableWidth = Math.max(1, width); + const indent = " "; + const separator = " · "; + const lines: string[] = []; + let currentLine = ""; + + for (const item of items) { + const candidate = currentLine + ? `${currentLine}${separator}${item}` + : visibleWidth(`${indent}${item}`) <= availableWidth + ? `${indent}${item}` + : item; + if (!currentLine || visibleWidth(candidate) <= availableWidth) { + currentLine = candidate; + continue; + } + + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item; + } + + if (currentLine) { + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + } + + return lines.map((line) => theme.fg("muted", line)); + } +} + +const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [ + { keys: ["tui.select.up", "tui.select.down"], label: "move" }, + { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" }, + { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" }, + { keys: ["app.tree.editLabel"], label: "label" }, + { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" }, + { + keys: [ + "app.tree.filter.default", + "app.tree.filter.noTools", + "app.tree.filter.userOnly", + "app.tree.filter.labeledOnly", + "app.tree.filter.all", + ], + label: "filters", + labelFirst: true, + }, + { keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true }, +]; + +function formatHelpKeys(keybindings: Keybinding[]): string { + const keys: string[] = []; + for (const keybinding of keybindings) { + const key = getKeybindings().getKeys(keybinding)[0]; + if (key !== undefined) keys.push(key); + } + if (keys.length === 0) return ""; + + return formatKeyText(compactRawKeys(keys)) + .replace(/\bpageUp\b/g, "pgup") + .replace(/\bpageDown\b/g, "pgdn") + .replace(/\bup\b/g, "↑") + .replace(/\bdown\b/g, "↓") + .replace(/\bleft\b/g, "←") + .replace(/\bright\b/g, "→"); +} + +function compactRawKeys(keys: string[]): string { + if (keys.length === 1) return keys[0]!; + + const parts = keys.map((key) => { + const separatorIndex = key.lastIndexOf("+"); + return separatorIndex === -1 + ? { prefix: "", suffix: key } + : { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) }; + }); + const prefix = parts[0]!.prefix; + return prefix && parts.every((part) => part.prefix === prefix) + ? `${prefix}${parts.map((part) => part.suffix).join("/")}` + : keys.join("/"); +} + /** Label input component shown when editing a label */ class LabelInput implements Component, Focusable { private input: Input; @@ -1181,25 +1334,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); - const filterKeys = [ - keyText("app.tree.filter.default"), - keyText("app.tree.filter.noTools"), - keyText("app.tree.filter.userOnly"), - keyText("app.tree.filter.labeledOnly"), - keyText("app.tree.filter.all"), - ].join("/"); - const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`; - const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`; - this.addChild( - new TruncatedText( - theme.fg( - "muted", - ` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`, - ), - 0, - 0, - ), - ); + this.addChild(new TreeHelp()); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts new file mode 100644 index 00000000..92c23288 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -0,0 +1,134 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { + getProjectTrustOptions, + type ProjectTrustOption, + type ProjectTrustStoreEntry, +} from "../../../core/trust-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export type TrustSelection = Pick; + +export interface TrustSelectorOptions { + cwd: string; + savedDecision: ProjectTrustStoreEntry | null; + projectTrusted: boolean; + onSelect: (selection: TrustSelection) => void; + onCancel: () => void; +} + +function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string { + if (decision === null) { + return "none"; + } + const label = decision.decision ? "trusted" : "untrusted"; + if (trustPath !== undefined && decision.path !== trustPath) { + return `${label} (inherited from ${decision.path})`; + } + return `${label} (${decision.path})`; +} + +export class TrustSelectorComponent extends Container { + private selectedIndex: number; + private readonly listContainer: Container; + private readonly trustOptions: ProjectTrustOption[]; + private readonly savedDecision: ProjectTrustStoreEntry | null; + private readonly onSelectCallback: (selection: TrustSelection) => void; + private readonly onCancelCallback: () => void; + + constructor(options: TrustSelectorOptions) { + super(); + + this.savedDecision = options.savedDecision; + this.trustOptions = getProjectTrustOptions(options.cwd); + this.selectedIndex = Math.max( + 0, + this.trustOptions.findIndex((option) => this.isSavedOption(option)), + ); + this.onSelectCallback = options.onSelect; + this.onCancelCallback = options.onCancel; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); + this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + theme.fg( + "muted", + `Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`, + ), + 1, + 0, + ), + ); + this.addChild( + new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), + ); + this.addChild(new Spacer(1)); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "save") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private isSavedOption(option: ProjectTrustOption): boolean { + return ( + option.savedPath !== undefined && + this.savedDecision?.decision === option.trusted && + this.savedDecision.path === option.savedPath + ); + } + + private updateList(): void { + this.listContainer.clear(); + for (let i = 0; i < this.trustOptions.length; i++) { + const option = this.trustOptions[i]; + if (!option) { + continue; + } + + const isSelected = i === this.selectedIndex; + const isCurrent = this.isSavedOption(option); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); + this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + const selected = this.trustOptions[this.selectedIndex]; + if (selected) { + this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 8fda83e9..45d65577 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -15,9 +15,16 @@ export class UserMessageComponent extends Container { super(); this.contentBox = new Box(1, 1, (content: string) => theme.bg("userMessageBg", content)); this.contentBox.addChild( - new Markdown(text, 0, 0, markdownTheme, { - color: (content: string) => theme.fg("userMessageText", content), - }), + new Markdown( + text, + 0, + 0, + markdownTheme, + { + color: (content: string) => theme.fg("userMessageText", content), + }, + { preserveOrderedListMarkers: true }, + ), ); this.addChild(this.contentBox); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 26e87dc9..0e9a9a0a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -45,10 +45,12 @@ import { TUI, visibleWidth, } from "@earendil-works/pi-tui"; +import chalk from "chalk"; import { spawn, spawnSync } from "child_process"; import { APP_NAME, APP_TITLE, + CONFIG_DIR_NAME, getAuthPath, getDebugLogPath, getDocsPath, @@ -66,6 +68,7 @@ import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, + ProjectTrustContext, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; @@ -80,7 +83,8 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; -import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; @@ -114,24 +118,24 @@ import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; +import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; +import { getModelSearchText } from "./model-search.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, - initTheme, onThemeChange, setRegisteredThemes, - setTheme, - setThemeInstance, stopThemeWatcher, Theme, type ThemeColor, theme, } from "./theme/theme.ts"; +import { InteractiveThemeController } from "./theme/theme-controller.ts"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -189,6 +193,28 @@ function isUnknownModel(model: Model | undefined): boolean { return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown"; } +function quoteIfNeeded(value: string): string { + if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function formatResumeCommand(sessionManager: SessionManager): string | undefined { + if (!process.stdout.isTTY) return undefined; + if (!sessionManager.isPersisted()) return undefined; + + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile || !fs.existsSync(sessionFile)) return undefined; + + const args = [APP_NAME]; + if (!sessionManager.usesDefaultSessionDir()) { + args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir())); + } + args.push("--session", sessionManager.getSessionId()); + return args.join(" "); +} + function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider { return providerId in defaultModelPerProvider; } @@ -219,6 +245,8 @@ export interface InteractiveModeOptions { migratedProviders?: string[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; + /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ + autoTrustOnReloadCwd?: string; /** Initial message to send on startup (can include @file content) */ initialMessage?: string; /** Images to attach to the initial message */ @@ -249,6 +277,7 @@ export class InteractiveMode { private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; + private pendingUserInputs: string[] = []; private loadingAnimation: Loader | undefined = undefined; private workingMessage: string | undefined = undefined; private workingVisible = true; @@ -336,6 +365,8 @@ export class InteractiveMode { private customHeader: (Component & { dispose?(): void }) | undefined = undefined; private options: InteractiveModeOptions; + private autoTrustOnReloadCwd: string | undefined; + private themeController: InteractiveThemeController; // Convenience accessors private get session(): AgentSession { @@ -354,6 +385,7 @@ export class InteractiveMode { constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; this.options = options; + this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; this.runtimeHost.setBeforeSessionInvalidate(() => { this.resetExtensionUI(); }); @@ -389,7 +421,12 @@ export class InteractiveMode { // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - initTheme(this.settingsManager.getTheme(), true); + this.themeController = new InteractiveThemeController( + this.ui, + this.settingsManager, + (message) => this.showError(message), + () => this.updateEditorBorderColor(), + ); } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { @@ -462,11 +499,12 @@ export class InteractiveMode { const items = models.map((m) => ({ id: m.id, provider: m.provider, + name: m.name, label: `${m.provider}/${m.id}`, })); - // Fuzzy filter by model ID + provider (allows "opus anthropic" to match) - const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`); + // Fuzzy filter by model ID + provider in either order. + const filtered = fuzzyFilter(items, prefix, getModelSearchText); if (filtered.length === 0) return null; @@ -519,8 +557,13 @@ export class InteractiveMode { private setupAutocompleteProvider(): void { let provider = this.createBaseAutocompleteProvider(); + const triggerCharacters: string[] = []; for (const wrapProvider of this.autocompleteProviderWrappers) { provider = wrapProvider(provider); + triggerCharacters.push(...(provider.triggerCharacters ?? [])); + } + if (triggerCharacters.length > 0) { + provider.triggerCharacters = [...new Set(triggerCharacters)]; } this.autocompleteProvider = provider; @@ -588,9 +631,28 @@ export class InteractiveMode { console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); } - // Add header container as first child + // Add header container as first child. Populate it after detectThemeIfUnset. this.ui.addChild(this.headerContainer); + this.ui.addChild(this.chatContainer); + this.ui.addChild(this.pendingMessagesContainer); + this.ui.addChild(this.statusContainer); + this.renderWidgets(); // Initialize with default spacer + this.ui.addChild(this.widgetContainerAbove); + this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); + this.ui.addChild(this.footer); + this.ui.setFocus(this.editor); + + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + + // Start the UI before initializing extensions so session_start handlers can use interactive dialogs + this.ui.start(); + this.isInitialized = true; + + await this.themeController.applyFromSettings(); + // Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); @@ -651,23 +713,7 @@ export class InteractiveMode { this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); } - - this.ui.addChild(this.chatContainer); - this.ui.addChild(this.pendingMessagesContainer); - this.ui.addChild(this.statusContainer); - this.renderWidgets(); // Initialize with default spacer - this.ui.addChild(this.widgetContainerAbove); - this.ui.addChild(this.editorContainer); - this.ui.addChild(this.widgetContainerBelow); - this.ui.addChild(this.footer); - this.ui.setFocus(this.editor); - - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs - this.ui.start(); - this.isInitialized = true; + this.ui.requestRender(); // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); @@ -845,7 +891,7 @@ export class InteractiveMode { if (newEntries.length > 0) { this.settingsManager.setLastChangelogVersion(VERSION); this.reportInstallTelemetry(VERSION); - return newEntries.map((e) => e.content).join("\n\n"); + return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n"); } return undefined; @@ -1542,6 +1588,7 @@ export class InteractiveMode { const uiContext = this.createExtensionUIContext(); await this.session.bindExtensions({ uiContext, + mode: "tui", abortHandler: () => { this.restoreQueuedMessagesToEditor({ abort: true }); }, @@ -1688,12 +1735,14 @@ export class InteractiveMode { // Create a context for shortcut handlers const createContext = (): ExtensionContext => ({ ui: this.createExtensionUIContext(), + mode: "tui", hasUI: true, cwd: this.sessionManager.getCwd(), sessionManager: this.sessionManager, modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), signal: this.session.agent.signal, abort: () => { this.restoreQueuedMessagesToEditor({ abort: true }); @@ -2019,6 +2068,21 @@ export class InteractiveMode { /** * Create the ExtensionUIContext for extensions. */ + private createProjectTrustContext(cwd: string): ProjectTrustContext { + const ui = this.createExtensionUIContext(); + return { + cwd, + mode: "tui", + hasUI: true, + ui: { + select: ui.select, + confirm: ui.confirm, + input: ui.input, + notify: ui.notify, + }, + }; + } + private createExtensionUIContext(): ExtensionUIContext { return { select: (title, options, opts) => this.showExtensionSelector(title, options, opts), @@ -2058,16 +2122,13 @@ export class InteractiveMode { getTheme: (name) => getThemeByName(name), setTheme: (themeOrName) => { if (themeOrName instanceof Theme) { - setThemeInstance(themeOrName); - this.ui.requestRender(); - return { success: true }; + return this.themeController.setThemeInstance(themeOrName); } - const result = setTheme(themeOrName, true); + const result = this.themeController.setThemeName(themeOrName); if (result.success) { if (this.settingsManager.getTheme() !== themeOrName) { this.settingsManager.setTheme(themeOrName); } - this.ui.requestRender(); } return result; }, @@ -2596,6 +2657,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/trust") { + this.showTrustSelector(); + this.editor.setText(""); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -2695,6 +2761,8 @@ export class InteractiveMode { if (this.onInputCallback) { this.onInputCallback(text); + } else { + this.pendingUserInputs.push(text); } this.editor.addToHistory?.(text); }; @@ -3136,6 +3204,7 @@ export class InteractiveMode { this.chatContainer.addChild(component); // Render user message separately if present if (skillBlock.userMessage) { + this.chatContainer.addChild(new Spacer(1)); const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), @@ -3255,6 +3324,7 @@ export class InteractiveMode { updateFooter: true, populateHistory: true, }); + this.renderProjectTrustWarningIfNeeded(); // Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); @@ -3265,7 +3335,32 @@ export class InteractiveMode { } } + private renderProjectTrustWarningIfNeeded(): void { + if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) { + return; + } + + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild( + new Text( + theme.fg( + "warning", + `This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`, + ), + 1, + 0, + ), + ); + } + async getUserInput(): Promise { + const queuedInput = this.pendingUserInputs.shift(); + if (queuedInput !== undefined) { + return queuedInput; + } + return new Promise((resolve) => { this.onInputCallback = (text: string) => { this.onInputCallback = undefined; @@ -3306,17 +3401,44 @@ export class InteractiveMode { */ private isShuttingDown = false; - private async shutdown(): Promise { + private async shutdown(options?: { fromSignal?: boolean }): Promise { if (this.isShuttingDown) return; this.isShuttingDown = true; - this.unregisterSignalHandlers(); + // Keep signal handlers registered until terminal cleanup has completed. + // `signal-exit` checks the listener list during the same SIGTERM/SIGHUP + // dispatch and re-sends the signal if only its own listeners remain. + if (options?.fromSignal) { + // Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup + // (session_shutdown) BEFORE touching the terminal. Extension teardown + // such as removing sockets does not write to the tty, so it must not be + // skipped if a later terminal-restore write fails on a dead or stalled + // terminal. If the terminal is gone, the restore writes below emit EIO, + // which the stdout/stderr error handler turns into emergencyTerminalExit; + // the render loop is already idle, so this cannot hot-spin (see #4144). + await this.runtimeHost.dispose(); + this.themeController.disableAutoSync(); + await this.ui.terminal.drainInput(1000); + this.stop(); + process.exit(0); + } + + // Interactive quit (Ctrl+D, Ctrl+C, /quit, extension shutdown()). Stop the + // TUI before emitting shutdown events so extension UI cleanup cannot repaint + // the final frame while the process is exiting. // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. + this.themeController.disableAutoSync(); await this.ui.terminal.drainInput(1000); this.stop(); await this.runtimeHost.dispose(); + + const resumeCommand = formatResumeCommand(this.sessionManager); + if (resumeCommand) { + process.stdout.write(`${chalk.dim("To resume this session:")} ${resumeCommand}\n`); + } + process.exit(0); } @@ -3377,11 +3499,12 @@ export class InteractiveMode { for (const signal of signals) { const handler = () => { - if (signal === "SIGHUP") { - this.emergencyTerminalExit(); - } + // SIGHUP no longer hard-exits: graceful shutdown emits session_shutdown + // first, then attempts terminal restore. A genuinely dead terminal + // surfaces as an EIO on the restore writes, which the stdout/stderr + // error handler converts into emergencyTerminalExit (see #4144, #5080). killTrackedDetachedChildren(); - void this.shutdown(); + void this.shutdown({ fromSignal: true }); }; process.prependListener(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); @@ -3635,7 +3758,6 @@ export class InteractiveMode { showError(errorMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); this.ui.requestRender(); } @@ -3862,7 +3984,8 @@ export class InteractiveMode { httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getTheme() || "dark", + currentTheme: this.settingsManager.getThemeSetting() || "dark", + terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), @@ -3870,6 +3993,7 @@ export class InteractiveMode { doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), editorPaddingX: this.settingsManager.getEditorPaddingX(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), @@ -3928,21 +4052,11 @@ export class InteractiveMode { this.footer.invalidate(); this.updateEditorBorderColor(); }, - onThemeChange: (themeName) => { - const result = setTheme(themeName, true); - this.settingsManager.setTheme(themeName); - this.ui.invalidate(); - if (!result.success) { - this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); - } - }, - onThemePreview: (themeName) => { - const result = setTheme(themeName, true); - if (result.success) { - this.ui.invalidate(); - this.ui.requestRender(); - } + onThemeChange: (themeSetting) => { + this.settingsManager.setTheme(themeSetting); + void this.themeController.applyFromSettings(); }, + onThemePreview: (themeName) => this.themeController.preview(themeName), onHideThinkingBlockChange: (hidden) => { this.hideThinkingBlock = hidden; this.settingsManager.setHideThinkingBlock(hidden); @@ -3963,6 +4077,9 @@ export class InteractiveMode { onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, + onDefaultProjectTrustChange: (defaultProjectTrust) => { + this.settingsManager.setDefaultProjectTrust(defaultProjectTrust); + }, onDoubleEscapeActionChange: (action) => { this.settingsManager.setDoubleEscapeAction(action); }, @@ -4088,6 +4205,57 @@ export class InteractiveMode { } } + private maybeSaveImplicitProjectTrustAfterReload(): boolean { + const cwd = this.sessionManager.getCwd(); + if (this.autoTrustOnReloadCwd !== cwd) { + return false; + } + if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) { + return false; + } + + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + try { + if (trustStore.get(cwd) !== null) { + this.autoTrustOnReloadCwd = undefined; + return false; + } + trustStore.set(cwd, true); + this.autoTrustOnReloadCwd = undefined; + return true; + } catch (error) { + this.showWarning( + `Could not save project trust after reload: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + + private showTrustSelector(): void { + const cwd = this.sessionManager.getCwd(); + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + const savedDecision = trustStore.getEntry(cwd); + this.showSelector((done) => { + const selector = new TrustSelectorComponent({ + cwd, + savedDecision, + projectTrusted: this.settingsManager.isProjectTrusted(), + onSelect: (selection) => { + trustStore.setMany(selection.updates); + done(); + this.showStatus( + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + ); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }); + return { component: selector, focus: selector }; + }); + } + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( @@ -4394,7 +4562,10 @@ export class InteractiveMode { const selector = new SessionSelectorComponent( (onProgress) => SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), - SessionManager.listAll, + (onProgress) => + this.sessionManager.usesDefaultSessionDir() + ? SessionManager.listAll(onProgress) + : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); @@ -4436,6 +4607,7 @@ export class InteractiveMode { try { const result = await this.runtimeHost.switchSession(sessionPath, { withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), }); if (result.cancelled) { return result; @@ -4453,6 +4625,7 @@ export class InteractiveMode { const result = await this.runtimeHost.switchSession(sessionPath, { cwdOverride: selectedCwd, withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), }); if (result.cancelled) { return result; @@ -4834,13 +5007,15 @@ export class InteractiveMode { manualCodeReject = undefined; } }); - } else if (providerId === "github-copilot") { - // GitHub Copilot polls after onAuth - dialog.showWaiting("Waiting for browser authentication..."); } // For Anthropic: onPrompt is called immediately after }, + onDeviceCode: (info) => { + dialog.showDeviceCode(info); + dialog.showWaiting("Waiting for authentication..."); + }, + onPrompt: async (prompt: { message: string; placeholder?: string }) => { return dialog.showPrompt(prompt.message, prompt.placeholder); }, @@ -4918,11 +5093,7 @@ export class InteractiveMode { } setRegisteredThemes(this.session.resourceLoader.getThemes().themes); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); - const themeName = this.settingsManager.getTheme(); - const themeResult = themeName ? setTheme(themeName, true) : { success: true }; - if (!themeResult.success) { - this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`); - } + await this.themeController.applyFromSettings(); const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor.setPaddingX(editorPaddingX); @@ -4942,11 +5113,16 @@ export class InteractiveMode { force: false, showDiagnosticsWhenQuiet: true, }); + const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload(); const modelsJsonError = this.session.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); } - this.showStatus("Reloaded keybindings, extensions, skills, prompts, themes"); + this.showStatus( + savedImplicitProjectTrust + ? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust" + : "Reloaded keybindings, extensions, skills, prompts, themes", + ); } catch (error) { dismissReloadBox(previousEditor as Component); this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); @@ -5222,7 +5398,7 @@ export class InteractiveMode { allEntries.length > 0 ? allEntries .reverse() - .map((e) => e.content) + .map((e) => normalizeChangelogLinks(e.content, e)) .join("\n\n") : "No changelog entries found."; @@ -5296,7 +5472,7 @@ export class InteractiveMode { **Navigation** | Key | Action | |-----|--------| -| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) | +| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history | | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | | \`${cursorLineStart}\` | Start of line | | \`${cursorLineEnd}\` | End of line | @@ -5529,14 +5705,6 @@ export class InteractiveMode { } private async handleCompactCommand(customInstructions?: string): Promise { - const entries = this.sessionManager.getEntries(); - const messageCount = entries.filter((e) => e.type === "message").length; - - if (messageCount < 2) { - this.showWarning("Nothing to compact (no messages yet)"); - return; - } - if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; @@ -5551,7 +5719,6 @@ export class InteractiveMode { } stop(): void { - this.unregisterSignalHandlers(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } @@ -5559,6 +5726,7 @@ export class InteractiveMode { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } + this.themeController.disableAutoSync(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); this.footerDataProvider.dispose(); @@ -5569,5 +5737,6 @@ export class InteractiveMode { this.ui.stop(); this.isInitialized = false; } + this.unregisterSignalHandlers(); } } diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts new file mode 100644 index 00000000..bab9c5a5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -0,0 +1,21 @@ +export interface ModelSearchItem { + id: string; + provider: string; + name?: string; +} + +export function getModelSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; +} + +/** + * The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs + * like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position. + */ +export function getModelSelectorSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${provider} ${provider}/${id} ${provider} ${id}${name}`; +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts new file mode 100644 index 00000000..9fe9e8cc --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -0,0 +1,135 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, + initTheme, + parseAutoThemeSetting, + resolveThemeSetting, + setTheme, + setThemeInstance, + type TerminalTheme, + type Theme, +} from "./theme.ts"; + +type ThemeResult = { success: boolean; error?: string }; + +export class InteractiveThemeController { + private readonly ui: TUI; + private readonly settingsManager: SettingsManager; + private readonly showError: (message: string) => void; + private readonly onChanged: () => void; + private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; + private activeThemeName: string | undefined; + private autoSyncEnabled = false; + + constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + this.ui = ui; + this.settingsManager = settingsManager; + this.showError = showError; + this.onChanged = onChanged; + this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + initTheme(this.activeThemeName, true); + this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme)); + } + + async applyFromSettings(): Promise { + const themeSetting = this.settingsManager.getThemeSetting(); + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + this.terminalTheme = await this.detectTerminalThemeForAuto(); + this.setAutoSync(true); + this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true); + return; + } + + this.setAutoSync(false); + if (themeSetting !== undefined) { + this.applyThemeName(themeSetting, true); + return; + } + + const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); + this.terminalTheme = detection.theme; + if (!this.applyThemeName(detection.theme).success) return; + if (detection.confidence === "high") { + this.settingsManager.setTheme(detection.theme); + await this.settingsManager.flush(); + } + } + + setThemeName(themeName: string, showError = false): ThemeResult { + this.setAutoSync(false); + return this.applyThemeName(themeName, showError); + } + + setThemeInstance(themeInstance: Theme): ThemeResult { + this.setAutoSync(false); + setThemeInstance(themeInstance); + this.activeThemeName = ""; + this.notifyChanged(); + return { success: true }; + } + + preview(themeSettingOrName: string): void { + const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName; + if (!themeName) return; + if (setTheme(themeName, true).success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + } + + disableAutoSync(): void { + this.setAutoSync(false); + } + + getTerminalTheme(): TerminalTheme { + return this.terminalTheme; + } + + private applyThemeName(themeName: string, showError = false): ThemeResult { + const result = setTheme(themeName, true); + this.activeThemeName = result.success ? themeName : "dark"; + this.notifyChanged(); + if (!result.success && showError) { + this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); + } + return result; + } + + private notifyChanged(): void { + this.ui.invalidate(); + this.onChanged(); + } + + private setAutoSync(enabled: boolean): void { + if (this.autoSyncEnabled === enabled) return; + this.autoSyncEnabled = enabled; + this.ui.setTerminalColorSchemeNotifications(enabled); + } + + private async detectTerminalThemeForAuto(): Promise { + try { + const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 }); + if (colorScheme) return colorScheme; + } catch { + // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + } + return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme; + } + + private applyTerminalTheme(terminalTheme: TerminalTheme): void { + if (!this.autoSyncEnabled) return; + this.terminalTheme = terminalTheme; + const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + if (!autoTheme) { + this.setAutoSync(false); + return; + } + const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + if (themeName !== this.activeThemeName) { + this.applyThemeName(themeName); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 7bc495da..9d94a12a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -11,7 +11,8 @@ }, "name": { "type": "string", - "description": "Theme name" + "pattern": "^[^/]+$", + "description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings." }, "vars": { "type": "object", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index d9bcdf8c..58e5aac3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -4,6 +4,7 @@ import { type EditorTheme, getCapabilities, type MarkdownTheme, + type RgbColor, type SelectListTheme, type SettingsListTheme, } from "@earendil-works/pi-tui"; @@ -502,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] { return result; } +function assertThemeNameIsValid(name: string): void { + if (name.includes("/")) { + throw new Error( + `Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`, + ); + } +} + function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); @@ -538,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson { throw new Error(errorMessage); } - return json as ThemeJson; + const themeJson = json as ThemeJson; + assertThemeNameIsValid(themeJson.name); + return themeJson; } function parseThemeJsonContent(label: string, content: string): ThemeJson { @@ -624,10 +635,34 @@ export function getThemeByName(name: string): Theme | undefined { export type TerminalTheme = "dark" | "light"; -export interface RgbColor { - r: number; - g: number; - b: number; +export function parseAutoThemeSetting( + themeSetting: string | undefined, +): { lightTheme: string; darkTheme: string } | undefined { + if (!themeSetting) return undefined; + const slashIndex = themeSetting.indexOf("/"); + if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) { + return undefined; + } + + const lightTheme = themeSetting.slice(0, slashIndex).trim(); + const darkTheme = themeSetting.slice(slashIndex + 1).trim(); + if (!lightTheme || !darkTheme) { + return undefined; + } + return { lightTheme, darkTheme }; +} + +export function resolveThemeSetting( + themeSetting: string | undefined, + terminalTheme: TerminalTheme, +): string | undefined { + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + } + if (themeSetting?.includes("/")) return undefined; + if (typeof themeSetting === "string") return themeSetting; + return undefined; } export interface TerminalThemeDetection { @@ -641,6 +676,15 @@ export interface TerminalThemeDetectionOptions { env?: NodeJS.ProcessEnv; } +export interface TerminalBackgroundThemeDetector { + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise; +} + +export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalBackgroundThemeDetector; + timeoutMs: number; +} + function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { const parts = colorfgbg.split(";"); for (let i = parts.length - 1; i >= 0; i--) { @@ -668,50 +712,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; } -function parseOscHexChannel(channel: string): number | undefined { - if (!/^[0-9a-f]+$/i.test(channel)) { - return undefined; - } - const max = 16 ** channel.length - 1; - if (max <= 0) { - return undefined; - } - return Math.round((parseInt(channel, 16) / max) * 255); -} - -export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { - const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i); - if (!match) { - return undefined; - } - - const value = match[1].trim(); - if (value.startsWith("#")) { - const hex = value.slice(1); - if (/^[0-9a-f]{6}$/i.test(hex)) { - return hexToRgb(value); - } - if (/^[0-9a-f]{12}$/i.test(hex)) { - const r = parseOscHexChannel(hex.slice(0, 4)); - const g = parseOscHexChannel(hex.slice(4, 8)); - const b = parseOscHexChannel(hex.slice(8, 12)); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; - } - return undefined; - } - - const rgbValue = value.replace(/^rgba?:/i, ""); - const [red, green, blue] = rgbValue.split("/"); - if (red === undefined || green === undefined || blue === undefined) { - return undefined; - } - const r = parseOscHexChannel(red); - const g = parseOscHexChannel(green); - const b = parseOscHexChannel(blue); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; -} - -export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { +export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { const env = options.env ?? process.env; const colorfgbg = env.COLORFGBG || ""; const bg = getColorFgBgBackgroundIndex(colorfgbg); @@ -732,8 +733,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions }; } +export async function detectTerminalBackgroundTheme({ + ui, + timeoutMs, + env, +}: TerminalBackgroundThemeDetectionOptions): Promise { + try { + const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs }); + if (rgb) { + return { + theme: getThemeForRgbColor(rgb), + source: "terminal background", + detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, + confidence: "high", + }; + } + } catch { + // Fall back to environment-based detection when the terminal query fails. + } + + return detectTerminalBackgroundFromEnv({ env }); +} + export function getDefaultTheme(): string { - return detectTerminalBackground().theme; + return detectTerminalBackgroundFromEnv().theme; } // ============================================================================ @@ -769,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void { registeredThemes.clear(); for (const theme of themes) { if (theme.name) { + assertThemeNameIsValid(theme.name); registeredThemes.set(theme.name, theme); } } @@ -1042,17 +1066,27 @@ function buildCliHighlightTheme(t: Theme): CliHighlightTheme { built_in: (s: string) => t.fg("syntaxType", s), literal: (s: string) => t.fg("syntaxNumber", s), number: (s: string) => t.fg("syntaxNumber", s), + regexp: (s: string) => t.fg("syntaxString", s), string: (s: string) => t.fg("syntaxString", s), comment: (s: string) => t.fg("syntaxComment", s), + doctag: (s: string) => t.fg("syntaxComment", s), + meta: (s: string) => t.fg("muted", s), function: (s: string) => t.fg("syntaxFunction", s), title: (s: string) => t.fg("syntaxFunction", s), class: (s: string) => t.fg("syntaxType", s), type: (s: string) => t.fg("syntaxType", s), + tag: (s: string) => t.fg("syntaxPunctuation", s), + name: (s: string) => t.fg("syntaxKeyword", s), attr: (s: string) => t.fg("syntaxVariable", s), variable: (s: string) => t.fg("syntaxVariable", s), params: (s: string) => t.fg("syntaxVariable", s), operator: (s: string) => t.fg("syntaxOperator", s), punctuation: (s: string) => t.fg("syntaxPunctuation", s), + emphasis: (s: string) => t.italic(s), + strong: (s: string) => t.bold(s), + link: (s: string) => t.underline(s), + addition: (s: string) => t.fg("toolDiffAdded", s), + deletion: (s: string) => t.fg("toolDiffRemoved", s), }; } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index c9553c55..a5ee0235 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -71,6 +71,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr const rebindSession = async (): Promise => { session = runtimeHost.session; await session.bindExtensions({ + mode: mode === "json" ? "json" : "print", commandContextActions: { waitForIdle: () => session.agent.waitForIdle(), newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions), diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 53b96e64..4c46feff 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -59,6 +59,7 @@ export class RpcClient { new Map(); private requestId = 0; private stderr = ""; + private exitError: Error | null = null; private options: RpcClientOptions; constructor(options: RpcClientOptions = {}) { @@ -73,6 +74,8 @@ export class RpcClient { throw new Error("Client already started"); } + this.exitError = null; + const cliPath = this.options.cliPath ?? "dist/cli.js"; const args = ["--mode", "rpc"]; @@ -86,20 +89,41 @@ export class RpcClient { args.push(...this.options.args); } - this.process = spawn("node", [cliPath, ...args], { + const childProcess = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); + this.process = childProcess; // Collect stderr for debugging - this.process.stderr?.on("data", (data) => { + childProcess.stderr?.on("data", (data) => { this.stderr += data.toString(); process.stderr.write(data); }); + childProcess.once("exit", (code, signal) => { + if (this.process !== childProcess) return; + const error = this.createProcessExitError(code, signal); + this.exitError = error; + this.rejectPendingRequests(error); + }); + childProcess.once("error", (error) => { + if (this.process !== childProcess) return; + const processError = new Error(`Agent process error: ${error.message}. Stderr: ${this.stderr}`); + this.exitError = processError; + this.rejectPendingRequests(processError); + }); + childProcess.stdin?.on("error", (error) => { + if (this.process !== childProcess) return; + const stdinError = + this.exitError ?? new Error(`Agent process stdin error: ${error.message}. Stderr: ${this.stderr}`); + this.exitError = stdinError; + this.rejectPendingRequests(stdinError); + }); + // Set up strict JSONL reader for stdout. - this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => { + this.stopReadingStdout = attachJsonlLineReader(childProcess.stdout!, (line) => { this.handleLine(line); }); @@ -107,7 +131,9 @@ export class RpcClient { await new Promise((resolve) => setTimeout(resolve, 100)); if (this.process.exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`); + const error = this.exitError ?? this.createProcessExitError(this.process.exitCode, this.process.signalCode); + this.exitError = error; + throw error; } } @@ -474,17 +500,41 @@ export class RpcClient { } } + private createProcessExitError(code: number | null, signal: NodeJS.Signals | null): Error { + return new Error(`Agent process exited (code=${code} signal=${signal}). Stderr: ${this.stderr}`); + } + + private rejectPendingRequests(error: Error): void { + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + this.pendingRequests.clear(); + } + private async send(command: RpcCommandBody): Promise { - if (!this.process?.stdin) { + const childProcess = this.process; + const stdin = childProcess?.stdin; + if (!childProcess || !stdin) { throw new Error("Client not started"); } + if (this.exitError) { + throw this.exitError; + } + if (childProcess.exitCode !== null) { + const error = this.createProcessExitError(childProcess.exitCode, childProcess.signalCode); + this.exitError = error; + throw error; + } + if (stdin.destroyed || !stdin.writable) { + const error = new Error(`Agent process stdin is not writable. Stderr: ${this.stderr}`); + this.exitError = error; + throw error; + } const id = `req_${++this.requestId}`; const fullCommand = { ...command, id } as RpcCommand; return new Promise((resolve, reject) => { - this.pendingRequests.set(id, { resolve, reject }); - const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); @@ -501,7 +551,14 @@ export class RpcClient { }, }); - this.process!.stdin!.write(serializeJsonLine(fullCommand)); + try { + stdin.write(serializeJsonLine(fullCommand)); + } catch (error: unknown) { + const writeError = error instanceof Error ? error : new Error(String(error)); + const pending = this.pendingRequests.get(id); + this.pendingRequests.delete(id); + pending?.reject(writeError); + } }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6caa942b..59a2b848 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -19,7 +19,12 @@ import type { ExtensionWidgetOptions, WorkingIndicatorOptions, } from "../../core/extensions/index.ts"; -import { takeOverStdout, writeRawStdout } from "../../core/output-guard.ts"; +import { + flushRawStdout, + takeOverStdout, + waitForRawStdoutBackpressure, + writeRawStdout, +} from "../../core/output-guard.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { type Theme, theme } from "../interactive/theme/theme.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; @@ -50,6 +55,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void) | undefined; + let unsubscribeBackpressure: (() => void) | undefined; const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { writeRawStdout(serializeJsonLine(obj)); @@ -312,6 +318,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise session.agent.waitForIdle(), newSession: async (options) => runtimeHost.newSession(options), @@ -344,9 +351,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { output(event); }); + unsubscribeBackpressure = session.agent.subscribe(async () => { + await waitForRawStdoutBackpressure(); + }); }; const registerSignalHandlers = (): void => { @@ -358,7 +369,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { killTrackedDetachedChildren(); - void shutdown(signal === "SIGHUP" ? 129 : 143); + void shutdown(signal === "SIGHUP" ? 129 : 143, signal); }; process.on(signal, handler); signalCleanupHandlers.push(() => process.off(signal, handler)); @@ -691,7 +702,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise {}; - async function shutdown(exitCode = 0): Promise { + async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise { if (shuttingDown) { process.exit(exitCode); } @@ -711,9 +722,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise [-l]`; + return `${APP_NAME} install [-l] [--approve|--no-approve]`; case "remove": - return `${APP_NAME} remove [-l]`; + return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension ] [--approve|--no-approve] [--force]`; case "list": - return `${APP_NAME} list`; + return `${APP_NAME} list [--approve|--no-approve]`; } } @@ -87,7 +95,9 @@ function printPackageCommandHelp(command: PackageCommand): void { Install a package and add it to settings. Options: - -l, --local Install project-locally (.pi/settings.json) + -l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} install npm:@foo/bar @@ -107,7 +117,9 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (.pi/settings.json) + -l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} remove npm:@foo/bar @@ -122,13 +134,17 @@ Examples: Update pi and installed packages. Options: - --self Update pi only + --self Update pi only (default when no target is given) --extensions Update installed packages only + --all Update pi and installed packages --extension Update one package only + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command --force Reinstall pi even if the current version is latest Short forms: - ${APP_NAME} update Update pi and all extensions + ${APP_NAME} update Update pi only + ${APP_NAME} update --all Update pi and all extensions ${APP_NAME} update Update one package ${APP_NAME} update pi Update pi only (self works as alias to pi) `); @@ -139,6 +155,10 @@ Short forms: ${getPackageCommandUsage("list")} List installed packages from user and project settings. + +Options: + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command `); return; } @@ -158,6 +178,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; + let projectTrustOverride: boolean | undefined; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -166,6 +187,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let source: string | undefined; let selfFlag = false; let extensionsFlag = false; + let allFlag = false; let extensionFlagSource: string | undefined; for (let index = 0; index < rest.length; index++) { @@ -202,6 +224,25 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--all") { + if (command === "update") { + allFlag = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + + if (arg === "--approve" || arg === "-a") { + projectTrustOverride = true; + continue; + } + + if (arg === "--no-approve" || arg === "-na") { + projectTrustOverride = false; + continue; + } + if (arg === "--force") { if (command === "update") { force = true; @@ -243,10 +284,20 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } let updateTarget: UpdateTarget | undefined; + let showExtensionsSkippedNote = false; if (command === "update") { + if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) { + conflictingOptions = + conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension"; + } + if (allFlag && source) { + conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source"; + } + if (extensionFlagSource) { - if (selfFlag || extensionsFlag) { - conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self or --extensions"; + if (selfFlag || extensionsFlag || allFlag) { + conflictingOptions = + conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all"; } if (source) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with a positional source"; @@ -257,12 +308,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined if (sourceIsSelf) { updateTarget = extensionsFlag ? { type: "all" } : { type: "self" }; } else { - if (extensionsFlag || selfFlag) { + if (extensionsFlag || selfFlag || allFlag) { conflictingOptions = - conflictingOptions ?? "positional update targets cannot be combined with --self or --extensions"; + conflictingOptions ?? + "positional update targets cannot be combined with --self, --extensions, or --all"; } updateTarget = { type: "extensions", source }; } + } else if (allFlag) { + updateTarget = { type: "all" }; } else if (selfFlag && extensionsFlag) { updateTarget = { type: "all" }; } else if (selfFlag) { @@ -270,7 +324,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } else if (extensionsFlag) { updateTarget = { type: "extensions" }; } else { - updateTarget = { type: "all" }; + updateTarget = { type: "self" }; + showExtensionsSkippedNote = true; } } @@ -278,8 +333,10 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined command, source, updateTarget, + showExtensionsSkippedNote, local, force, + projectTrustOverride, help, invalidOption, invalidArgument, @@ -389,14 +446,102 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } -export async function handleConfigCommand(args: string[]): Promise { +function parseProjectTrustOverride(args: readonly string[]): boolean | undefined { + let trustOverride: boolean | undefined; + for (const arg of args) { + if (arg === "--approve" || arg === "-a") { + trustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + trustOverride = false; + } + } + return trustOverride; +} + +export interface PackageCommandRuntimeOptions { + extensionFactories?: ExtensionFactory[]; +} + +interface CommandSettingsResult { + settingsManager: SettingsManager; + projectTrustWarnings: string[]; +} + +function getCommandAppMode(): AppMode { + return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print"; +} + +function reportProjectTrustWarnings(warnings: readonly string[]): void { + for (const warning of warnings) { + console.error(chalk.yellow(`Warning: ${warning}`)); + } +} + +async function createCommandSettingsManager(options: { + cwd: string; + agentDir: string; + projectTrustOverride?: boolean; + useSavedProjectTrustOnly?: boolean; + extensionFactories?: ExtensionFactory[]; +}): Promise { + const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); + const projectTrustWarnings: string[] = []; + const trustStore = new ProjectTrustStore(options.agentDir); + if (options.useSavedProjectTrustOnly) { + const savedProjectTrusted = trustStore.get(options.cwd) === true; + settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted); + return { settingsManager, projectTrustWarnings }; + } + + const appMode = getCommandAppMode(); + const extensionsResult = + options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd) + ? await new DefaultResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + extensionFactories: options.extensionFactories, + }).loadProjectTrustExtensions() + : undefined; + for (const error of extensionsResult?.errors ?? []) { + projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`); + } + + const projectTrusted = await resolveProjectTrusted({ + cwd: options.cwd, + trustStore, + trustOverride: options.projectTrustOverride, + defaultProjectTrust: settingsManager.getDefaultProjectTrust(), + extensionsResult, + projectTrustContext: createProjectTrustContext({ + cwd: options.cwd, + mode: appMode, + settingsManager, + hasUI: appMode === "interactive", + }), + onExtensionError: (message) => projectTrustWarnings.push(message), + }); + settingsManager.setProjectTrusted(projectTrusted); + return { settingsManager, projectTrustWarnings }; +} + +export async function handleConfigCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { if (args[0] !== "config") { return false; } const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: parseProjectTrustOverride(args), + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -411,7 +556,10 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } -export async function handlePackageCommand(args: string[]): Promise { +export async function handlePackageCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { const options = parsePackageCommand(args); if (!options) { return false; @@ -460,7 +608,20 @@ export async function handlePackageCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: options.projectTrustOverride, + useSavedProjectTrustOnly: options.command === "update", + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); + if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) { + console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); + process.exitCode = 1; + return true; + } reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; @@ -527,7 +688,12 @@ export async function handlePackageCommand(args: string[]): Promise { } case "update": { - const target = options.updateTarget ?? { type: "all" }; + const target = options.updateTarget ?? { type: "self" }; + if (options.showExtensionsSkippedNote) { + console.log( + chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`), + ); + } if (updateTargetIncludesExtensions(target)) { const updateSource = target.type === "extensions" ? target.source : undefined; await packageManager.update(updateSource); diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index b9e8e35d..2c8ce4a6 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { existsSync, readFileSync } from "fs"; export interface ChangelogEntry { @@ -7,6 +8,102 @@ export interface ChangelogEntry { content: string; } +const GITHUB_REPO = "earendil-works/pi"; +const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function entryVersion(entry: ChangelogEntry): string { + return `${entry.major}.${entry.minor}.${entry.patch}`; +} + +function normalizeTag(version: string | ChangelogEntry): string { + const versionString = typeof version === "string" ? version : entryVersion(version); + return versionString.startsWith("v") ? versionString : `v${versionString}`; +} + +function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value: string): string { + return value.replaceAll("\\", "/"); +} + +function resolveRepositoryPath(targetPath: string): string | undefined { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeChangelogLinkTarget(target: string, tag: string): string { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`); + const repoUrl = `https://github.com/${GITHUB_REPO}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string { + const tag = normalizeTag(version); + return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => { + return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`; + }); +} + /** * Parse changelog entries from CHANGELOG.md * Scans for ## lines and collects content until next ## or EOF diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index 3d97d3da..b152444d 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -38,10 +38,13 @@ export function spawnProcessSync( /** * Wait for a child process to terminate without hanging on inherited stdio handles. * - * On Windows, daemonized descendants can inherit the child's stdout/stderr pipe - * handles. In that case the child emits `exit`, but `close` can hang forever even - * though the original process is already gone. We wait briefly for stdio to end, - * then forcibly stop tracking the inherited handles. + * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr + * pipe open. We must not resolve and destroy the streams on a fixed deadline measured + * from `exit`, or output still being written past that deadline is silently lost + * (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle: + * the grace timer is re-armed on every chunk, so an actively writing descendant keeps + * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant + * that never lets `close` fire) still releases us after the grace elapses. */ export function waitForChildProcess(child: ChildProcess): Promise { return new Promise((resolve, reject) => { @@ -62,6 +65,8 @@ export function waitForChildProcess(child: ChildProcess): Promise child.removeListener("close", onClose); child.stdout?.removeListener("end", onStdoutEnd); child.stderr?.removeListener("end", onStderrEnd); + child.stdout?.removeListener("data", onData); + child.stderr?.removeListener("data", onData); }; const finalize = (code: number | null) => { @@ -80,6 +85,17 @@ export function waitForChildProcess(child: ChildProcess): Promise } }; + const armIdleTimer = () => { + if (postExitTimer) clearTimeout(postExitTimer); + postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS); + }; + + const onData = () => { + // Output is still arriving after exit; defer finalizing so we don't + // destroy the stream mid-write and truncate the tail. + if (exited && !settled) armIdleTimer(); + }; + const onStdoutEnd = () => { stdoutEnded = true; maybeFinalizeAfterExit(); @@ -102,7 +118,7 @@ export function waitForChildProcess(child: ChildProcess): Promise exitCode = code; maybeFinalizeAfterExit(); if (!settled) { - postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS); + armIdleTimer(); } }; @@ -112,6 +128,8 @@ export function waitForChildProcess(child: ChildProcess): Promise child.stdout?.once("end", onStdoutEnd); child.stderr?.once("end", onStderrEnd); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); child.once("error", onError); child.once("exit", onExit); child.once("close", onClose); diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts index bcdbdfa8..f8aeb547 100644 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ b/packages/coding-agent/src/utils/clipboard-native.ts @@ -1,4 +1,6 @@ import { createRequire } from "module"; +import { dirname, join } from "path"; +import { pathToFileURL } from "url"; export type ClipboardModule = { setText: (text: string) => Promise; @@ -6,17 +8,25 @@ export type ClipboardModule = { getImageBinary: () => Promise>; }; -const require = createRequire(import.meta.url); -let clipboard: ClipboardModule | null = null; +type ClipboardRequire = (id: string) => unknown; +const moduleRequire = createRequire(import.meta.url); +const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href); const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); -if (!process.env.TERMUX_VERSION && hasDisplay) { - try { - clipboard = require("@mariozechner/clipboard") as ClipboardModule; - } catch { - clipboard = null; +export function loadClipboardNative( + requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire], +): ClipboardModule | null { + for (const requireClipboard of requires) { + try { + return requireClipboard("@mariozechner/clipboard") as ClipboardModule; + } catch { + // Try the next resolution root. + } } + return null; } +const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null; + export { clipboard }; diff --git a/packages/coding-agent/src/utils/deprecation.ts b/packages/coding-agent/src/utils/deprecation.ts new file mode 100644 index 00000000..78a2f146 --- /dev/null +++ b/packages/coding-agent/src/utils/deprecation.ts @@ -0,0 +1,14 @@ +import chalk from "chalk"; + +const emittedDeprecationWarnings = new Set(); + +export function warnDeprecation(message: string): void { + if (emittedDeprecationWarnings.has(message)) return; + emittedDeprecationWarnings.add(message); + console.warn(chalk.yellow(`Deprecation warning: ${message}`)); +} + +/** Clear deprecation warning state. Exported for tests. */ +export function clearDeprecationWarningsForTests(): void { + emittedDeprecationWarnings.clear(); +} diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 9652e401..1314edea 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -73,6 +73,56 @@ function splitRef(url: string): { repo: string; ref?: string } { }; } +function decodeForValidation(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function hasUnsafeGitInstallPart(value: string, allowSlash: boolean): boolean { + const decoded = decodeForValidation(value); + if (decoded === null) { + return true; + } + const candidates = [value, decoded]; + for (const candidate of candidates) { + if (candidate.includes("\0") || candidate.includes("\\") || candidate.startsWith("/")) { + return true; + } + if (!allowSlash && candidate.includes("/")) { + return true; + } + if (candidate.split("/").includes("..")) { + return true; + } + } + return false; +} + +function buildGitSource(args: { repo: string; host: string; path: string; ref?: string }): GitSource | null { + if (args.path.startsWith("/")) { + return null; + } + const normalizedPath = args.path.replace(/\.git$/, "").replace(/^\/+/, ""); + if (!args.host || !normalizedPath || normalizedPath.split("/").length < 2) { + return null; + } + if (hasUnsafeGitInstallPart(args.host, false) || hasUnsafeGitInstallPart(normalizedPath, true)) { + return null; + } + + return { + type: "git", + repo: args.repo, + host: args.host, + path: normalizedPath, + ref: args.ref, + pinned: Boolean(args.ref), + }; +} + function parseGenericGitUrl(url: string): GitSource | null { const { repo: repoWithoutRef, ref } = splitRef(url); let repo = repoWithoutRef; @@ -109,19 +159,7 @@ function parseGenericGitUrl(url: string): GitSource | null { repo = `https://${repoWithoutRef}`; } - const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, ""); - if (!host || !normalizedPath || normalizedPath.split("/").length < 2) { - return null; - } - - return { - type: "git", - repo, - host, - path: normalizedPath, - ref, - pinned: Boolean(ref), - }; + return buildGitSource({ repo, host, path, ref }); } /** @@ -157,14 +195,12 @@ export function parseGitUrl(source: string): GitSource | null { !split.repo.startsWith("ssh://") && !split.repo.startsWith("git://") && !split.repo.startsWith("git@"); - return { - type: "git", + return buildGitSource({ repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, host: info.domain || "", - path: `${info.user}/${info.project}`.replace(/\.git$/, ""), + path: `${info.user}/${info.project}`, ref: info.committish || split.ref || undefined, - pinned: Boolean(info.committish || split.ref), - }; + }); } } @@ -177,14 +213,12 @@ export function parseGitUrl(source: string): GitSource | null { if (split.ref && info.project?.includes("@")) { continue; } - return { - type: "git", + return buildGitSource({ repo: `https://${split.repo}`, host: info.domain || "", - path: `${info.user}/${info.project}`.replace(/\.git$/, ""), + path: `${info.user}/${info.project}`, ref: info.committish || split.ref || undefined, - pinned: Boolean(info.committish || split.ref), - }; + }); } } diff --git a/packages/coding-agent/src/utils/image-resize-core.ts b/packages/coding-agent/src/utils/image-resize-core.ts new file mode 100644 index 00000000..e60820c7 --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-core.ts @@ -0,0 +1,164 @@ +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +interface EncodedCandidate { + data: string; + encodedSize: number; + mimeType: string; +} + +function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImageInProcess( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4; + + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + let image: ReturnType | undefined; + try { + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) rawImage.free(); + + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = mimeType.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND encoded size) + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { + return { + data: Buffer.from(inputBytes).toString("base64"), + mimeType: mimeType || `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { + const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); + + try { + const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } finally { + resized.free(); + } + } + + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + + if (currentWidth === 1 && currentHeight === 1) { + break; + } + + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return null; + } catch { + return null; + } finally { + if (image) { + image.free(); + } + } +} diff --git a/packages/coding-agent/src/utils/image-resize-worker.ts b/packages/coding-agent/src/utils/image-resize-worker.ts new file mode 100644 index 00000000..ee881b3d --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-worker.ts @@ -0,0 +1,42 @@ +import { parentPort } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; + +interface ResizeImageWorkerRequest { + inputBytes: Uint8Array; + mimeType: string; + options?: ImageResizeOptions; +} + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; +} + +function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string"; +} + +const port = parentPort; +if (!port) { + throw new Error("image resize worker requires parentPort"); +} + +port.once("message", (message: unknown) => { + void (async () => { + try { + if (!isResizeImageWorkerRequest(message)) { + throw new Error("Invalid image resize worker request"); + } + const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options); + const response: ResizeImageWorkerResponse = { result }; + port.postMessage(response); + } catch (error) { + const response: ResizeImageWorkerResponse = { + error: error instanceof Error ? error.message : String(error), + }; + port.postMessage(response); + } + })(); +}); diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 3eb17dae..516a1e57 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,164 +1,111 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; -import { applyExifOrientation } from "./exif-orientation.ts"; -import { loadPhoton } from "./photon.ts"; +import { Worker } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; -export interface ImageResizeOptions { - maxWidth?: number; // Default: 2000 - maxHeight?: number; // Default: 2000 - maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) - jpegQuality?: number; // Default: 80 +export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; } -export interface ResizedImage { - data: string; // base64 - mimeType: string; - originalWidth: number; - originalHeight: number; - width: number; - height: number; - wasResized: boolean; +function toTransferableBytes(input: Uint8Array): Uint8Array { + // Transfer detaches the buffer, so transfer a worker-owned copy and leave the + // caller's bytes intact. + return new Uint8Array(input); } -// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. -const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; - -const DEFAULT_OPTIONS: Required = { - maxWidth: 2000, - maxHeight: 2000, - maxBytes: DEFAULT_MAX_BYTES, - jpegQuality: 80, -}; - -interface EncodedCandidate { - data: string; - encodedSize: number; - mimeType: string; +function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse { + return value !== null && typeof value === "object"; } -function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { - const data = Buffer.from(buffer).toString("base64"); - return { - data, - encodedSize: Buffer.byteLength(data, "utf-8"), - mimeType, - }; +function createResizeWorker(workerSpecifier: string | URL): Worker { + return new Worker(workerSpecifier); +} + +async function resizeImageInWorker( + workerSpecifier: string | URL, + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const worker = createResizeWorker(workerSpecifier); + try { + const inputBytesForWorker = toTransferableBytes(inputBytes); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (result: ResizedImage | null): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const fail = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + + worker.once("message", (message: unknown) => { + if (!isResizeImageWorkerResponse(message)) { + fail(new Error("Invalid image resize worker response")); + return; + } + if (message.error) { + fail(new Error(message.error)); + return; + } + settle(message.result ?? null); + }); + worker.once("error", fail); + worker.once("exit", (code) => { + if (!settled) { + fail(new Error(`Image resize worker exited with code ${code}`)); + } + }); + worker.postMessage( + { + inputBytes: inputBytesForWorker, + mimeType, + options, + }, + [inputBytesForWorker.buffer], + ); + }); + } finally { + void worker.terminate().catch(() => undefined); + } } /** * Resize an image to fit within the specified max dimensions and encoded file size. - * Returns null if the image cannot be resized below maxBytes. - * - * Uses Photon (Rust/WASM) for image processing. If Photon is not available, - * returns null. - * - * Strategy for staying under maxBytes: - * 1. First resize to maxWidth/maxHeight - * 2. Try both PNG and JPEG formats, pick the smaller one - * 3. If still too large, try JPEG with decreasing quality - * 4. If still too large, progressively reduce dimensions until 1x1 + * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not + * block the TUI event loop. If the worker cannot be loaded (for example in some + * Bun compiled executable layouts), fall back to in-process resizing so image + * reads still work. */ -export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise { - const opts = { ...DEFAULT_OPTIONS, ...options }; - const inputBuffer = Buffer.from(img.data, "base64"); - const inputBase64Size = Buffer.byteLength(img.data, "utf-8"); +export async function resizeImage( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); + const workerUrl = new URL( + isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", + import.meta.url, + ); - const photon = await loadPhoton(); - if (!photon) { - return null; + // Bun compiled executables resolve worker entrypoints by string path, not via + // new URL(..., import.meta.url). Try the string path first under Bun so the + // release binary uses the embedded worker instead of falling back in-process. + if (typeof process.versions.bun === "string") { + try { + return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options); + } catch {} } - let image: ReturnType | undefined; try { - const inputBytes = new Uint8Array(inputBuffer); - const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); - image = applyExifOrientation(photon, rawImage, inputBytes); - if (image !== rawImage) rawImage.free(); - - const originalWidth = image.get_width(); - const originalHeight = image.get_height(); - const format = img.mimeType?.split("/")[1] ?? "png"; - - // Check if already within all limits (dimensions AND encoded size) - if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { - return { - data: img.data, - mimeType: img.mimeType ?? `image/${format}`, - originalWidth, - originalHeight, - width: originalWidth, - height: originalHeight, - wasResized: false, - }; - } - - // Calculate initial dimensions respecting max limits - let targetWidth = originalWidth; - let targetHeight = originalHeight; - - if (targetWidth > opts.maxWidth) { - targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); - targetWidth = opts.maxWidth; - } - if (targetHeight > opts.maxHeight) { - targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); - targetHeight = opts.maxHeight; - } - - function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { - const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); - - try { - const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; - for (const quality of jpegQualities) { - candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); - } - return candidates; - } finally { - resized.free(); - } - } - - const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); - let currentWidth = targetWidth; - let currentHeight = targetHeight; - - while (true) { - const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); - for (const candidate of candidates) { - if (candidate.encodedSize < opts.maxBytes) { - return { - data: candidate.data, - mimeType: candidate.mimeType, - originalWidth, - originalHeight, - width: currentWidth, - height: currentHeight, - wasResized: true, - }; - } - } - - if (currentWidth === 1 && currentHeight === 1) { - break; - } - - const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); - const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); - if (nextWidth === currentWidth && nextHeight === currentHeight) { - break; - } - - currentWidth = nextWidth; - currentHeight = nextHeight; - } - - return null; + return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options); } catch { - return null; - } finally { - if (image) { - image.free(); - } + return resizeImageInProcess(inputBytes, mimeType, options); } } diff --git a/packages/coding-agent/src/utils/json.ts b/packages/coding-agent/src/utils/json.ts new file mode 100644 index 00000000..9ee7b7b1 --- /dev/null +++ b/packages/coding-agent/src/utils/json.ts @@ -0,0 +1,6 @@ +/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ +export function stripJsonComments(input: string): string { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); +} diff --git a/packages/coding-agent/src/utils/open-browser.ts b/packages/coding-agent/src/utils/open-browser.ts new file mode 100644 index 00000000..435e23f9 --- /dev/null +++ b/packages/coding-agent/src/utils/open-browser.ts @@ -0,0 +1,24 @@ +import { spawn } from "node:child_process"; + +/** + * Open a URL or file in the platform browser/default handler. + * + * This intentionally never invokes a shell. On Windows, do not use + * `cmd /c start`: cmd.exe re-parses metacharacters (&, |, ^, ...) before + * `start` runs, which would make attacker-controlled URLs injectable. + */ +export function openBrowser(target: string): void { + const [cmd, args]: [string, string[]] = + process.platform === "darwin" + ? ["open", [target]] + : process.platform === "win32" + ? ["rundll32", ["url.dll,FileProtocolHandler", target]] + : ["xdg-open", [target]]; + + // spawn reports launcher failures (for example, missing xdg-open) via an + // error event. Browser launch is best-effort: callers still present the target + // to the user, so keep the launcher failure from becoming a process crash. + spawn(cmd, args, { stdio: "ignore", detached: true }) + .on("error", () => {}) + .unref(); +} diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 817ba4f9..2cafa595 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -6,11 +6,21 @@ import { getBinDir } from "../config.ts"; export interface ShellConfig { shell: string; args: string[]; + commandTransport?: "argv" | "stdin"; } /** * Find bash executable on PATH (cross-platform) */ +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) @@ -58,7 +68,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { // 1. Check user-specified shell path if (customShellPath) { if (existsSync(customShellPath)) { - return { shell: customShellPath, args: ["-c"] }; + return getBashShellConfig(customShellPath); } throw new Error(`Custom shell path not found: ${customShellPath}`); } @@ -77,14 +87,14 @@ export function getShellConfig(customShellPath?: string): ShellConfig { for (const path of paths) { if (existsSync(path)) { - return { shell: path, args: ["-c"] }; + return getBashShellConfig(path); } } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) const bashOnPath = findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return getBashShellConfig(bashOnPath); } throw new Error( @@ -98,12 +108,12 @@ export function getShellConfig(customShellPath?: string): ShellConfig { // Unix: try /bin/bash, then bash on PATH, then fallback to sh if (existsSync("/bin/bash")) { - return { shell: "/bin/bash", args: ["-c"] }; + return getBashShellConfig("/bin/bash"); } const bashOnPath = findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return getBashShellConfig(bashOnPath); } return { shell: "sh", args: ["-c"] }; diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index aec25bdb..a6a4f469 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -1,3 +1,4 @@ +import { compare, valid } from "semver"; import { getPiUserAgent } from "./pi-user-agent.ts"; const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; @@ -9,40 +10,13 @@ export interface LatestPiRelease { note?: string; } -interface ParsedVersion { - major: number; - minor: number; - patch: number; - prerelease?: string; -} - -function parsePackageVersion(version: string): ParsedVersion | undefined { - const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); - if (!match) { - return undefined; - } - return { - major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - patch: Number.parseInt(match[3], 10), - prerelease: match[4], - }; -} - export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined { - const left = parsePackageVersion(leftVersion); - const right = parsePackageVersion(rightVersion); + const left = valid(leftVersion.trim()); + const right = valid(rightVersion.trim()); if (!left || !right) { return undefined; } - - if (left.major !== right.major) return left.major - right.major; - if (left.minor !== right.minor) return left.minor - right.minor; - if (left.patch !== right.patch) return left.patch - right.patch; - if (left.prerelease === right.prerelease) return 0; - if (!left.prerelease) return 1; - if (!right.prerelease) return -1; - return left.prerelease.localeCompare(right.prerelease); + return compare(left, right); } export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean { diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 1abe3e28..7fd56b46 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -2,7 +2,12 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { type AssistantMessage, getModel } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + createAssistantMessageEventStream, + fauxAssistantMessage, + getModel, +} from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -11,51 +16,10 @@ import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; import { createTestResourceLoader } from "./utilities.ts"; -vi.mock("../src/core/compaction/index.js", () => ({ - calculateContextTokens: (usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens?: number; - }) => usage.totalTokens ?? usage.input + usage.output + usage.cacheRead + usage.cacheWrite, - collectEntriesForBranchSummary: () => ({ entries: [], commonAncestorId: null }), - compact: async () => ({ - summary: "compacted", - firstKeptEntryId: "entry-1", - tokensBefore: 100, - details: {}, - }), - estimateContextTokens: ( - messages: Array<{ - role: string; - usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens?: number }; - stopReason?: string; - }>, - ) => { - // Walk backwards to find last non-error, non-aborted assistant with usage - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === "assistant" && msg.stopReason !== "error" && msg.stopReason !== "aborted" && msg.usage) { - const tokens = - msg.usage.totalTokens ?? msg.usage.input + msg.usage.output + msg.usage.cacheRead + msg.usage.cacheWrite; - return { tokens, usageTokens: tokens, trailingTokens: 0, lastUsageIndex: i }; - } - } - return { tokens: 0, usageTokens: 0, trailingTokens: 0, lastUsageIndex: null }; - }, - generateBranchSummary: async () => ({ summary: "", aborted: false, readFiles: [], modifiedFiles: [] }), - prepareCompaction: () => ({ dummy: true }), - shouldCompact: ( - contextTokens: number, - contextWindow: number, - settings: { enabled: boolean; reserveTokens: number }, - ) => settings.enabled && contextTokens > contextWindow - settings.reserveTokens, -})); - describe("AgentSession auto-compaction queue resume", () => { let session: AgentSession; let sessionManager: SessionManager; + let settingsManager: SettingsManager; let tempDir: string; beforeEach(() => { @@ -73,7 +37,7 @@ describe("AgentSession auto-compaction queue resume", () => { }); sessionManager = SessionManager.inMemory(); - const settingsManager = SettingsManager.create(tempDir, tempDir); + settingsManager = SettingsManager.create(tempDir, tempDir); const authStorage = AuthStorage.create(join(tempDir, "auth.json")); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = ModelRegistry.create(authStorage, tempDir); @@ -98,6 +62,57 @@ describe("AgentSession auto-compaction queue resume", () => { }); it("should resume after threshold compaction when only agent-level queued messages exist", async () => { + settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const model = session.model!; + const now = Date.now(); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "assistant response to compact" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 100, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: now - 500, + }); + session.agent.state.messages = sessionManager.buildSessionContext().messages; + session.agent.streamFn = (summaryModel) => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "stop", + message: { + ...fauxAssistantMessage("compacted"), + api: summaryModel.api, + provider: summaryModel.provider, + model: summaryModel.id, + usage: { + input: 10, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 10, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + }); + }); + return stream; + }; + session.agent.followUp({ role: "custom", customType: "test", diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 81f400ea..dcd2ac9b 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -444,6 +444,7 @@ describe("AgentSession concurrent prompt guard", () => { text: string, images: unknown, source: "interactive" | "rpc" | "extension", + streamingBehavior?: "steer" | "followUp", ) => Promise<{ action: "continue" }>; emitBeforeAgentStart: ( prompt: string, @@ -588,6 +589,7 @@ describe("AgentSession concurrent prompt guard", () => { text: string, images: unknown, source: "interactive" | "rpc" | "extension", + streamingBehavior?: "steer" | "followUp", ) => Promise<{ action: "continue" }>; emitBeforeAgentStart: ( prompt: string, diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index e58ea22f..cf64a17f 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -72,6 +72,9 @@ describe("AgentSession dynamic tool registration", () => { const readTool = allTools.find((tool) => tool.name === "read"); expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool"); + expect(dynamicTool?.promptGuidelines).toEqual([ + "Use dynamic_tool when the user asks for dynamic behavior tests.", + ]); expect(dynamicTool?.sourceInfo).toMatchObject({ path: "", source: "inline", diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 3bf41473..8591974d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -130,6 +130,11 @@ describe("parseArgs", () => { expect(result.session).toBe("/path/to/session.jsonl"); }); + test("parses --session-id", () => { + const result = parseArgs(["--session-id", "orchestrated-session"]); + expect(result.sessionId).toBe("orchestrated-session"); + }); + test("parses --fork", () => { const result = parseArgs(["--fork", "1234abcd"]); expect(result.fork).toBe("1234abcd"); @@ -152,6 +157,36 @@ describe("parseArgs", () => { }); }); + describe("--name flag", () => { + test("parses --name flag with value", () => { + const result = parseArgs(["--name", "my-session"]); + expect(result.name).toBe("my-session"); + }); + + test("parses -n shorthand", () => { + const result = parseArgs(["-n", "quick-session"]); + expect(result.name).toBe("quick-session"); + }); + + test("preserves empty values for main validation", () => { + const result = parseArgs(["--name", ""]); + expect(result.name).toBe(""); + }); + + test("reports missing value", () => { + const result = parseArgs(["--name"]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]); + }); + + test("works alongside other flags", () => { + const result = parseArgs(["--name", "named-run", "--print", "--model", "gpt-4o", "hello"]); + expect(result.name).toBe("named-run"); + expect(result.print).toBe(true); + expect(result.model).toBe("gpt-4o"); + expect(result.messages).toEqual(["hello"]); + }); + }); + describe("--no-session flag", () => { test("parses --no-session flag", () => { const result = parseArgs(["--no-session"]); @@ -258,6 +293,28 @@ describe("parseArgs", () => { }); }); + describe("project approval flags", () => { + test("parses --approve", () => { + const result = parseArgs(["--approve"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses -a shorthand", () => { + const result = parseArgs(["-a"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses --no-approve", () => { + const result = parseArgs(["--no-approve"]); + expect(result.projectTrustOverride).toBe(false); + }); + + test("parses -na shorthand", () => { + const result = parseArgs(["-na"]); + expect(result.projectTrustOverride).toBe(false); + }); + }); + describe("--verbose flag", () => { test("parses --verbose flag", () => { const result = parseArgs(["--verbose"]); @@ -303,6 +360,16 @@ describe("parseArgs", () => { expect(result.tools).toEqual(["read", "bash"]); }); + test("parses --exclude-tools flag", () => { + const result = parseArgs(["--exclude-tools", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + + test("parses -xt shorthand", () => { + const result = parseArgs(["-xt", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + test("parses --no-tools with explicit --tools flags", () => { const result = parseArgs(["--no-tools", "--tools", "read,bash"]); expect(result.noTools).toBe(true); diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 9ec97100..651f9a17 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -5,7 +5,8 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; -import { clearConfigValueCache } from "../src/core/resolve-config-value.ts"; +import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts"; +import * as shellModule from "../src/utils/shell.ts"; describe("AuthStorage", () => { let tempDir: string; @@ -112,13 +113,13 @@ describe("AuthStorage", () => { expect(apiKey).toBeUndefined(); }); - test("apiKey as environment variable name resolves to env value", async () => { + test("apiKey with $ prefix resolves to env value", async () => { const originalEnv = process.env.TEST_AUTH_API_KEY_12345; process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; try { writeAuthJson({ - anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" }, + anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" }, }); authStorage = AuthStorage.create(authJsonPath); @@ -134,6 +135,168 @@ describe("AuthStorage", () => { } }); + test("apiKey env bag takes precedence over process.env", async () => { + const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345; + process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value"; + + try { + writeAuthJson({ + anthropic: { + type: "api_key", + key: "$TEST_AUTH_SCOPED_API_KEY_12345", + env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" }, + }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value"); + expect(authStorage.getProviderEnv("anthropic")).toEqual({ + TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value", + }); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_SCOPED_API_KEY_12345; + } else { + process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey with braced env syntax resolves to env value", async () => { + const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345; + process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value"; + const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: bracedKey }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("braced-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_BRACED_API_KEY_12345; + } else { + process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey interpolates braced env references inside literals", async () => { + const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345; + const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345; + process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left"; + process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right"; + const interpolatedKey = [ + "$", + "{TEST_AUTH_INTERPOLATED_PART_A_12345}_$", + "{TEST_AUTH_INTERPOLATED_PART_B_12345}", + ].join(""); + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: interpolatedKey }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("left_right"); + } finally { + if (originalPartA === undefined) { + delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345; + } else { + process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA; + } + if (originalPartB === undefined) { + delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345; + } else { + process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB; + } + } + }); + + test("apiKey with $$ prefix escapes a leading dollar", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345"); + }); + + test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => { + const originalEnv = process.env.TEST_AUTH_API_KEY_12345; + process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: "$!literal-$TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("!literal-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_API_KEY_12345; + } else { + process.env.TEST_AUTH_API_KEY_12345 = originalEnv; + } + } + }); + + test("plain API key is used directly even when it matches an env var", async () => { + const originalEnv = process.env.TEST_AUTH_API_KEY_12345; + process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("TEST_AUTH_API_KEY_12345"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_API_KEY_12345; + } else { + process.env.TEST_AUTH_API_KEY_12345 = originalEnv; + } + } + }); + + test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => { + const originalPublic = process.env.PUBLIC; + process.env.PUBLIC = "C:\\Users\\Public"; + + try { + writeAuthJson({ + opencode: { type: "api_key", key: "public" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("opencode"); + + expect(apiKey).toBe("public"); + } finally { + if (originalPublic === undefined) { + delete process.env.PUBLIC; + } else { + process.env.PUBLIC = originalPublic; + } + } + }); + test("apiKey as literal value is used directly when not an env var", async () => { // Make sure this isn't an env var delete process.env.literal_api_key_value; @@ -159,6 +322,30 @@ describe("AuthStorage", () => { expect(apiKey).toBe("hello-world"); }); + test("command config uses stdin when configured shell requires it", () => { + if (process.platform === "win32") return; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + vi.spyOn(shellModule, "getShellConfig").mockReturnValue({ + shell: "/bin/bash", + args: ["-s"], + commandTransport: "stdin", + }); + + try { + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + const nameExpansion = "$" + "{name}"; + + expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!"); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + describe("caching", () => { test("command is only executed once per process", async () => { // Use a command that writes to a file to count invocations @@ -274,7 +461,7 @@ describe("AuthStorage", () => { process.env[envVarName] = "first-value"; writeAuthJson({ - anthropic: { type: "api_key", key: envVarName }, + anthropic: { type: "api_key", key: `$${envVarName}` }, }); authStorage = AuthStorage.create(authJsonPath); diff --git a/packages/coding-agent/test/changelog.test.ts b/packages/coding-agent/test/changelog.test.ts new file mode 100644 index 00000000..979e7cdf --- /dev/null +++ b/packages/coding-agent/test/changelog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts"; + +const entry: ChangelogEntry = { + major: 0, + minor: 79, + patch: 0, + content: "", +}; + +describe("normalizeChangelogLinks", () => { + test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => { + const markdown = [ + "[Project Trust](README.md#project-trust)", + "[Extensions](docs/extensions.md#project_trust)", + "[Examples](examples/extensions/)", + "[Root README](../../README.md#supply-chain-hardening)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, entry)).toBe( + [ + "[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)", + "[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)", + "[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)", + "[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)", + ].join("\n"), + ); + }); + + test("canonicalizes old repository URLs without changing external links", () => { + const markdown = [ + "[#5167](https://github.com/earendil-works/pi-mono/pull/5167)", + "[#4163](https://github.com/badlogic/pi-mono/issues/4163)", + "[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe( + [ + "[#5167](https://github.com/earendil-works/pi/pull/5167)", + "[#4163](https://github.com/earendil-works/pi/issues/4163)", + "[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"), + ); + }); +}); diff --git a/packages/coding-agent/test/clipboard-native.test.ts b/packages/coding-agent/test/clipboard-native.test.ts new file mode 100644 index 00000000..a9c0c201 --- /dev/null +++ b/packages/coding-agent/test/clipboard-native.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from "vitest"; +import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts"; + +type ClipboardRequire = (id: string) => unknown; + +const fakeClipboard: ClipboardModule = { + setText: async () => {}, + hasImage: () => true, + getImageBinary: async () => [1, 2, 3], +}; + +describe("loadClipboardNative", () => { + test("falls back to the next require root", () => { + const primary = vi.fn(() => { + throw new Error("missing from bundled root"); + }); + const fallback = vi.fn(() => fakeClipboard); + + expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard); + expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard"); + expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard"); + }); + + test("returns null when no require root can load clipboard", () => { + const missing = vi.fn(() => { + throw new Error("missing"); + }); + + expect(loadClipboardNative([missing])).toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 2c41210e..c95125f2 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { const sessionManager = SessionManager.create(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir); + settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const modelRegistry = ModelRegistry.create(authStorage); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 929d06ec..be3d5c63 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -9,7 +9,6 @@ import { calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, - estimateContextTokens, findCutPoint, getLastAssistantUsage, prepareCompaction, @@ -396,7 +395,7 @@ describe("buildSessionContext", () => { }); describe("prepareCompaction with previous compaction", () => { - it("should preserve kept messages across repeated compactions when they still fit", () => { + it("should skip repeated compactions when kept messages still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1")); const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1")); @@ -408,29 +407,9 @@ describe("prepareCompaction with previous compaction", () => { const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000))); const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4]; - const contextBefore = buildSessionContext(pathEntries); const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS); - expect(preparation).toBeDefined(); - expect(preparation!.firstKeptEntryId).toBe(u2.id); - expect(preparation!.previousSummary).toBe("First summary"); - expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary"); - expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens); - - const compaction2: CompactionEntry = { - type: "compaction", - id: "compaction2-id", - parentId: a4.id, - timestamp: new Date().toISOString(), - summary: "Second summary", - firstKeptEntryId: preparation!.firstKeptEntryId, - tokensBefore: preparation!.tokensBefore, - }; - const contextAfter = buildSessionContext([...pathEntries, compaction2]); - const contextAfterText = extractText(contextAfter.messages); - - expect(contextAfterText).toContain("user msg 2 - kept by compaction1"); - expect(contextAfterText).toContain("user msg 3 - kept by compaction1"); + expect(preparation).toBeUndefined(); }); it("should re-summarize previously kept messages when the recent window moves past them", () => { diff --git a/packages/coding-agent/test/config-value-migration.test.ts b/packages/coding-agent/test/config-value-migration.test.ts new file mode 100644 index 00000000..8a273b53 --- /dev/null +++ b/packages/coding-agent/test/config-value-migration.test.ts @@ -0,0 +1,177 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { runMigrations } from "../src/migrations.ts"; + +describe("config value env var syntax migration", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + function createAgentDir(): string { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-")); + tempDirs.push(agentDir); + return agentDir; + } + + function withAgentDir(agentDir: string, fn: () => void): void { + const previousAgentDir = process.env[ENV_AGENT_DIR]; + process.env[ENV_AGENT_DIR] = agentDir; + try { + fn(); + } finally { + if (previousAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = previousAgentDir; + } + } + } + + it("leaves uppercase auth.json API key values unchanged", () => { + const agentDir = createAgentDir(); + fs.writeFileSync( + path.join(agentDir, "auth.json"), + `${JSON.stringify( + { + anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" }, + openai: { type: "api_key", key: "$OPENAI_API_KEY" }, + opencode: { type: "api_key", key: "public" }, + github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + withAgentDir(agentDir, () => runMigrations(agentDir)); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record< + string, + Record + >; + expect(migrated.anthropic.key).toBe("ANTHROPIC_API_KEY"); + expect(migrated.openai.key).toBe("$OPENAI_API_KEY"); + expect(migrated.opencode.key).toBe("public"); + expect(migrated.github.access).toBe("ACCESS_TOKEN"); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["malformed", '{\n "providers": {\n'], + ["blank", ""], + ])("does not throw on %s models.json during migrations", (_name, content) => { + const agentDir = createAgentDir(); + const modelsPath = path.join(agentDir, "models.json"); + fs.writeFileSync(modelsPath, content, "utf-8"); + + withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow()); + + expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content); + const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath); + const loadError = registry.getError(); + expect(loadError).toContain("Failed to parse models.json"); + expect(loadError).toContain(`File: ${modelsPath}`); + }); + + it("leaves uppercase models.json API key and header values unchanged", async () => { + const agentDir = createAgentDir(); + const envKeys = ["CUSTOM_API_KEY", "HEADER_API_KEY", "MODEL_API_KEY", "OVERRIDE_API_KEY"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } + + try { + fs.writeFileSync( + path.join(agentDir, "models.json"), + `${JSON.stringify( + { + providers: { + "custom-provider": { + baseUrl: "https://example.com/v1", + apiKey: "CUSTOM_API_KEY", + api: "openai-completions", + headers: { + "x-api-key": "HEADER_API_KEY", + "x-literal": "literal", + }, + models: [ + { + id: "model-a", + headers: { "x-model-key": "MODEL_API_KEY" }, + }, + ], + modelOverrides: { + "model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + withAgentDir(agentDir, () => runMigrations(agentDir)); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as { + providers: Record< + string, + { + apiKey?: string; + headers?: Record; + models?: Array<{ headers?: Record }>; + modelOverrides?: Record }>; + } + >; + }; + const provider = migrated.providers["custom-provider"]!; + expect(provider.apiKey).toBe("CUSTOM_API_KEY"); + expect(provider.headers?.["x-api-key"]).toBe("HEADER_API_KEY"); + expect(provider.headers?.["x-literal"]).toBe("literal"); + expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("MODEL_API_KEY"); + expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY"); + expect(logSpy).not.toHaveBeenCalled(); + + const registry = ModelRegistry.create( + AuthStorage.create(path.join(agentDir, "auth.json")), + path.join(agentDir, "models.json"), + ); + const model = registry.find("custom-provider", "model-a"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyForProvider("custom-provider")).toBe("CUSTOM_API_KEY"); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_API_KEY", + headers: { + "x-api-key": "HEADER_API_KEY", + "x-literal": "literal", + "x-model-key": "MODEL_API_KEY", + }, + }); + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + } + }); +}); diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 6b90778a..cbfa044e 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -153,7 +153,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: pnpm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "Run: pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @earendil-works/pi-coding-agent", ); }); @@ -175,8 +175,16 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, + args: [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@earendil-works/pi-coding-agent", + ], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, }); }); @@ -187,8 +195,8 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], + display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, steps: [ { command: "npm", @@ -197,8 +205,8 @@ describe("detectInstallMethod", () => { }, { command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, }, ], }); @@ -211,8 +219,16 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, + args: [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@earendil-works/pi-coding-agent", + ], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, }); }); @@ -227,6 +243,7 @@ describe("detectInstallMethod", () => { "install", "-g", "--ignore-scripts", + "--min-release-age=0", "@earendil-works/pi-coding-agent", ]); }); @@ -237,7 +254,7 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent"); expect(command?.display).toBe( - `npm --prefix "${prefix}" install -g --ignore-scripts @earendil-works/pi-coding-agent`, + `npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, ); }); @@ -248,7 +265,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "Run: npm install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent", ); }); @@ -260,8 +277,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: "bun install -g --ignore-scripts @earendil-works/pi-coding-agent", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@earendil-works/pi-coding-agent"], + display: "bun install -g --ignore-scripts --minimum-release-age=0 @earendil-works/pi-coding-agent", }); }); @@ -273,8 +290,9 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(command).toEqual({ command: "pnpm", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"], + display: + "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi", steps: [ { command: "pnpm", @@ -283,8 +301,8 @@ describe("detectInstallMethod", () => { }, { command: "pnpm", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "pnpm install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"], + display: "pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi", }, ], }); @@ -328,8 +346,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(command).toEqual({ command: "pnpm", - args: ["install", "-g", "--ignore-scripts", packageName], - display: `pnpm install -g --ignore-scripts ${packageName}`, + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", packageName], + display: `pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 ${packageName}`, }); }); @@ -366,8 +384,9 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"], + display: + "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi", steps: [ { command: "bun", @@ -376,8 +395,8 @@ describe("detectInstallMethod", () => { }, { command: "bun", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "bun install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"], + display: "bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi", }, ], }); diff --git a/packages/coding-agent/test/experimental.test.ts b/packages/coding-agent/test/experimental.test.ts new file mode 100644 index 00000000..665616e8 --- /dev/null +++ b/packages/coding-agent/test/experimental.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts"; + +describe("areExperimentalFeaturesEnabled", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + + afterEach(() => { + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + }); + + it("returns false when PI_EXPERIMENTAL is unset", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is empty", () => { + process.env.PI_EXPERIMENTAL = ""; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns true when PI_EXPERIMENTAL is set to 1", () => { + process.env.PI_EXPERIMENTAL = "1"; + + expect(areExperimentalFeaturesEnabled()).toBe(true); + }); + + it("returns false when PI_EXPERIMENTAL is set to 0", () => { + process.env.PI_EXPERIMENTAL = "0"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => { + process.env.PI_EXPERIMENTAL = "true"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/export-html-xss.test.ts b/packages/coding-agent/test/export-html-xss.test.ts index 5c6ffe87..da3c19f3 100644 --- a/packages/coding-agent/test/export-html-xss.test.ts +++ b/packages/coding-agent/test/export-html-xss.test.ts @@ -4,15 +4,20 @@ import { describe, expect, it } from "vitest"; describe("export HTML markdown link sanitization", () => { const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); - it("overrides the marked link renderer to block javascript: protocol", () => { - // The custom link renderer must check for dangerous protocols + it("overrides the marked link renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/); - expect(templateJs).toMatch(/javascript/i); - expect(templateJs).toMatch(/vbscript/i); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/); }); - it("overrides the marked image renderer to block javascript: protocol", () => { + it("overrides the marked image renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + }); + + it("strips C0 controls before checking and emitting markdown URLs", () => { + expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')"); + expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i); }); it("escapes href attributes in the custom link renderer", () => { diff --git a/packages/coding-agent/test/extensions-input-event.test.ts b/packages/coding-agent/test/extensions-input-event.test.ts index 3d5ae5cc..357fb6b8 100644 --- a/packages/coding-agent/test/extensions-input-event.test.ts +++ b/packages/coding-agent/test/extensions-input-event.test.ts @@ -94,6 +94,18 @@ describe("Input Event", () => { } }); + it("passes streamingBehavior correctly", async () => { + const r = await createRunner( + `export default p => p.on("input", async e => { globalThis.testVar = e.streamingBehavior; return { action: "continue" }; });`, + ); + await r.emitInput("x", undefined, "interactive", "steer"); + expect((globalThis as any).testVar).toBe("steer"); + await r.emitInput("x", undefined, "interactive", "followUp"); + expect((globalThis as any).testVar).toBe("followUp"); + await r.emitInput("x", undefined, "interactive"); + expect((globalThis as any).testVar).toBeUndefined(); + }); + it("catches handler errors and continues", async () => { const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`); const errs: string[] = []; diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 03de2d77..cd611962 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -7,9 +7,14 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; -import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; -import { ExtensionRunner } from "../src/core/extensions/runner.ts"; -import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.ts"; +import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts"; +import type { + ExtensionActions, + ExtensionContextActions, + ExtensionUIContext, + ProviderConfig, +} from "../src/core/extensions/types.ts"; import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts"; import { ModelRegistry } from "../src/core/model-registry.ts"; import { SessionManager } from "../src/core/session-manager.ts"; @@ -36,7 +41,7 @@ describe("ExtensionRunner", () => { const providerModelConfig: ProviderConfig = { baseUrl: "https://provider.test/v1", - apiKey: "PROVIDER_TEST_KEY", + apiKey: "provider-test-key", api: "openai-completions", models: [ { @@ -71,6 +76,7 @@ describe("ExtensionRunner", () => { const extensionContextActions: ExtensionContextActions = { getModel: () => undefined, isIdle: () => true, + isProjectTrusted: () => true, getSignal: () => undefined, abort: () => {}, hasPendingMessages: () => false, @@ -80,6 +86,45 @@ describe("ExtensionRunner", () => { getSystemPrompt: () => "", }; + describe("project_trust", () => { + it("continues past undecided handlers and returns the first yes/no decision", async () => { + const undecidedPath = path.join(extensionsDir, "undecided.ts"); + const decidedPath = path.join(extensionsDir, "decided.ts"); + fs.writeFileSync( + undecidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "undecided", remember: true })); +}`, + ); + fs.writeFileSync( + decidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "no", remember: true })); +}`, + ); + + const extensionsResult = await loadExtensions([undecidedPath, decidedPath], tempDir); + const result = await emitProjectTrustEvent( + extensionsResult, + { type: "project_trust", cwd: tempDir }, + { + cwd: tempDir, + mode: "tui", + hasUI: false, + ui: { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + }, + }, + ); + + expect(result.result).toEqual({ trusted: "no", remember: true }); + expect(result.errors).toEqual([]); + }); + }); + describe("shortcut conflicts", () => { it("warns when extension shortcut conflicts with built-in", async () => { const extCode = ` @@ -441,6 +486,50 @@ describe("ExtensionRunner", () => { controller.abort(); expect(ctx.signal?.aborted).toBe(true); }); + + it("exposes print mode and hasUI false by default", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("print"); + expect(ctx.hasUI).toBe(false); + }); + + it("exposes project trust state on ExtensionContext", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, { + ...extensionContextActions, + isProjectTrusted: () => false, + }); + + const ctx = runner.createContext(); + expect(ctx.isProjectTrusted()).toBe(false); + }); + + it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + runner.setUIContext({} as ExtensionUIContext, "rpc"); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("rpc"); + expect(ctx.hasUI).toBe(true); + }); + + it("exposes tui mode with hasUI true when a TUI UI context is provided", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + runner.setUIContext({} as ExtensionUIContext, "tui"); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("tui"); + expect(ctx.hasUI).toBe(true); + }); }); describe("error handling", () => { diff --git a/packages/coding-agent/test/file-mutation-queue.test.ts b/packages/coding-agent/test/file-mutation-queue.test.ts index 73d20c6d..8e13a45e 100644 --- a/packages/coding-agent/test/file-mutation-queue.test.ts +++ b/packages/coding-agent/test/file-mutation-queue.test.ts @@ -10,6 +10,18 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function resolvesWithin(promise: Promise, ms: number): Promise { + return Promise.race([promise.then(() => true), delay(ms).then(() => false)]); +} + const tempDirs: string[] = []; async function createTempDir(): Promise { @@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => { const content = await readFile(filePath, "utf8"); expect(content).toBe("replacement\n"); }); + + it("keeps write queue locked while an aborted write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-write.txt"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const writeTool = createWriteTool(dir, { + operations: { + mkdir: async () => {}, + writeFile: async (path, content) => { + if (content === "first\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "second\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal); + await firstWriteStarted.promise; + controller.abort(); + + const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstWrite).rejects.toThrow("Operation aborted"); + await secondWrite; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("second\n"); + }); + + it("keeps edit queue locked while an aborted edit write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-edit.txt"); + await writeFile(filePath, "alpha\nbeta\n", "utf8"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const editTool = createEditTool(dir, { + operations: { + access, + readFile, + writeFile: async (path, content) => { + if (content === "ALPHA\nbeta\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstEdit = editTool.execute( + "call-1", + { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] }, + controller.signal, + ); + await firstWriteStarted.promise; + controller.abort(); + + const secondEdit = editTool.execute("call-2", { + path: filePath, + edits: [{ oldText: "beta", newText: "BETA" }], + }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstEdit).rejects.toThrow("Operation aborted"); + await secondEdit; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("ALPHA\nBETA\n"); + }); }); diff --git a/packages/coding-agent/test/first-time-setup-fork.test.ts b/packages/coding-agent/test/first-time-setup-fork.test.ts new file mode 100644 index 00000000..e9b89207 --- /dev/null +++ b/packages/coding-agent/test/first-time-setup-fork.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/config.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + PACKAGE_NAME: "@example/pi-coding-agent", + }; +}); + +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; + +describe("shouldRunFirstTimeSetup in forked distributions", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-fork-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + }); + + it("returns false for a forked package", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/first-time-setup.test.ts b/packages/coding-agent/test/first-time-setup.test.ts new file mode 100644 index 00000000..c470e6dc --- /dev/null +++ b/packages/coding-agent/test/first-time-setup.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("shouldRunFirstTimeSetup", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + const originalAgentDir = process.env[ENV_AGENT_DIR]; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + delete process.env[ENV_AGENT_DIR]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + if (originalAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = originalAgentDir; + } + }); + + it("returns true when experimental, default agent dir, and no settings.json", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true); + }); + + it("returns false when experimental features are disabled", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when a custom agent dir is set", () => { + process.env[ENV_AGENT_DIR] = tempDir; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when settings.json already exists", () => { + writeFileSync(settingsPath, "{}", "utf-8"); + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); + +describe("analytics settings", () => { + it("defaults to disabled with no tracking identifier", () => { + const manager = SettingsManager.inMemory(); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("generates a tracking identifier on opt-in", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + + expect(manager.getEnableAnalytics()).toBe(true); + expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("does not generate a tracking identifier on opt-out", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(false); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("keeps the tracking identifier when toggling analytics", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + const trackingId = manager.getTrackingId(); + manager.setEnableAnalytics(false); + manager.setEnableAnalytics(true); + + expect(manager.getTrackingId()).toBe(trackingId); + }); +}); diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 2d912647..51df1eda 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -2,8 +2,9 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; -import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; +import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; type AssistantUsage = { input: number; @@ -73,6 +74,17 @@ function createFooterData(providerCount: number): ReadonlyFooterDataProvider { return provider; } +describe("formatCwdForFooter", () => { + it("does not abbreviate sibling paths that share the home prefix", () => { + expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2"); + }); + + it("abbreviates the home directory and descendants", () => { + expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~"); + expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project"); + }); +}); + describe("FooterComponent width handling", () => { beforeAll(() => { initTheme(undefined, false); @@ -112,4 +124,21 @@ describe("FooterComponent width handling", () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it("shows the latest cache hit rate when cache usage is present", () => { + const session = createSession({ + sessionName: "", + usage: { + input: 100, + output: 10, + cacheRead: 50, + cacheWrite: 50, + cost: { total: 0.001 }, + }, + }); + const footer = new FooterComponent(session, createFooterData(1)); + + const statsLine = stripAnsi(footer.render(120)[1]); + expect(statsLine).toContain("CH25.0%"); + }); }); diff --git a/packages/coding-agent/test/format-resume-command.test.ts b/packages/coding-agent/test/format-resume-command.test.ts new file mode 100644 index 00000000..86c4475b --- /dev/null +++ b/packages/coding-agent/test/format-resume-command.test.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { APP_NAME } from "../src/config.ts"; +import type { SessionManager } from "../src/core/session-manager.ts"; +import { formatResumeCommand } from "../src/modes/interactive/interactive-mode.ts"; + +const tempDirs: string[] = []; +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +afterEach(() => { + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function setStdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value }); +} + +function createTempFile(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-format-resume-command-")); + tempDirs.push(dir); + const file = join(dir, "session.jsonl"); + writeFileSync(file, "\n"); + return file; +} + +function createSessionManager(options: { + persisted?: boolean; + sessionFile?: string; + sessionId?: string; + sessionDir?: string; + usesDefaultSessionDir?: boolean; +}): SessionManager { + return { + isPersisted: () => options.persisted ?? true, + getSessionFile: () => options.sessionFile, + getSessionId: () => options.sessionId ?? "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d3", + getSessionDir: () => options.sessionDir ?? "/tmp/pi-sessions", + usesDefaultSessionDir: () => options.usesDefaultSessionDir ?? true, + } as unknown as SessionManager; +} + +describe("formatResumeCommand", () => { + it("returns a session resume command for default session dirs", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ sessionFile, sessionId: "test-session" }); + + expect(formatResumeCommand(sessionManager)).toBe(`${APP_NAME} --session test-session`); + }); + + it("includes unquoted safe session dirs for non-default session dirs", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom-pi-sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir /tmp/custom-pi-sessions --session test-session`, + ); + }); + + it("quotes session dirs containing spaces", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom pi sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir '/tmp/custom pi sessions' --session test-session`, + ); + }); + + it("quotes session dirs containing single quotes", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom pi's sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir '/tmp/custom pi'\\''s sessions' --session test-session`, + ); + }); + + it("returns undefined when stdout is not a TTY", () => { + setStdoutIsTTY(false); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ sessionFile }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined for in-memory sessions", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ persisted: false, sessionFile }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined when the session file is missing", () => { + setStdoutIsTTY(true); + const sessionManager = createSessionManager({ sessionFile: "/tmp/pi-missing-session.jsonl" }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined when the session file is not set", () => { + setStdoutIsTTY(true); + const sessionManager = createSessionManager({ sessionFile: undefined }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts b/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts new file mode 100644 index 00000000..7c8741c7 --- /dev/null +++ b/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts @@ -0,0 +1,206 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import mergeAndResolve from "../examples/extensions/git-merge-and-resolve.ts"; +import type { ExecResult, ExtensionAPI, ExtensionContext } from "../src/core/extensions/index.ts"; + +type AgentEndHandler = (event: { type: "agent_end" }, ctx: ExtensionContext) => Promise; + +const ok: ExecResult = { stdout: "", stderr: "", code: 0, killed: false }; +const fail: ExecResult = { stdout: "", stderr: "error", code: 1, killed: false }; + +/** Standard exec results for a clean repo tracking origin/main, not in a merge. */ +function withUpstream(results: Map): Map { + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", fail); + results.set("git status --porcelain", ok); + results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { ...ok, stdout: "origin/main\n" }); + results.set("git fetch origin", ok); + return results; +} + +function setup(cwd: string, execResults: Map) { + let handler: AgentEndHandler | undefined; + const sendUserMessage = vi.fn(); + + const exec = vi.fn().mockImplementation(async (cmd, args) => { + const key = [cmd, ...args].join(" "); + return execResults.get(key) ?? fail; + }); + + const api = { + on: (event: string, h: AgentEndHandler) => { + if (event === "agent_end") handler = h; + }, + exec, + sendUserMessage, + } as unknown as ExtensionAPI; + + mergeAndResolve(api); + + const ctx = { cwd, ui: { notify: vi.fn() } } as unknown as ExtensionContext; + + async function trigger() { + await handler!({ type: "agent_end" }, ctx); + } + + return { trigger, exec, sendUserMessage }; +} + +describe("git-merge-and-resolve example", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + function createTempDir() { + tempDir = mkdtempSync(join(tmpdir(), "pi-merge-test-")); + return tempDir; + } + + it("skips when not a git repository", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", fail); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(exec).toHaveBeenCalledTimes(1); + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when no upstream is configured", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", fail); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("re-sends conflicts when in an unfinished merge", async () => { + const cwd = createTempDir(); + const conflictContent = ["<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> origin/main"].join("\n"); + writeFileSync(join(cwd, "file.ts"), conflictContent); + + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", ok); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "file.ts\n" }); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + // Should not attempt a new fetch/merge + expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]); + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const message = sendUserMessage.mock.calls[0][0] as string; + expect(message).toContain("file.ts:1-5"); + }); + + it("skips when working tree is dirty and not in a merge", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", fail); + results.set("git status --porcelain", { ...ok, stdout: " M src/index.ts\n" }); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]); + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when fetch fails", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git fetch origin", fail); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when merge is clean", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", ok); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("sends conflict report as a follow-up", async () => { + const cwd = createTempDir(); + const conflictContent = [ + "line 1", + "<<<<<<< HEAD", + "our change", + "=======", + "their change", + ">>>>>>> origin/main", + "line 7", + "<<<<<<< HEAD", + "second conflict", + "=======", + "their second", + ">>>>>>> origin/main", + ].join("\n"); + + mkdirSync(join(cwd, "src"), { recursive: true }); + writeFileSync(join(cwd, "src/index.ts"), conflictContent); + + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "src/index.ts\n" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const [message, options] = sendUserMessage.mock.calls[0]; + expect(message).toContain("src/index.ts:2-6 (ours 3, theirs 5)"); + expect(message).toContain("src/index.ts:8-12 (ours 9, theirs 11)"); + expect(options).toEqual({ deliverAs: "followUp" }); + }); + + it("handles empty ours or theirs sections", async () => { + const cwd = createTempDir(); + const conflictContent = ["<<<<<<< HEAD", "=======", "only theirs", ">>>>>>> origin/main"].join("\n"); + + writeFileSync(join(cwd, "empty-ours.ts"), conflictContent); + + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "empty-ours.ts\n" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const message = sendUserMessage.mock.calls[0][0] as string; + expect(message).toContain("empty-ours.ts:1-4 (ours empty, theirs 3)"); + }); + + it("skips message when merge fails but no conflict markers found", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/git-ssh-url.test.ts b/packages/coding-agent/test/git-ssh-url.test.ts index 6913b70c..87b8b940 100644 --- a/packages/coding-agent/test/git-ssh-url.test.ts +++ b/packages/coding-agent/test/git-ssh-url.test.ts @@ -62,6 +62,19 @@ describe("Git URL Parsing", () => { }); }); + it("should reject unsafe git install path inputs", () => { + for (const source of [ + "git:git@evil.example:../../victim/repo", + "https://evil.example/..%2F..%2Fvictim/repo", + "https://evil.example/..%2F..%2Fvictim/repo%", + "git:git@evil.example:/absolute/repo", + "git:git@evil.example:user\\repo/name", + "git:git@evil.example:user/repo\0name", + ]) { + expect(parseGitUrl(source)).toBeNull(); + } + }); + describe("unsupported without git: prefix", () => { it("should reject git@host:path without git: prefix", () => { expect(parseGitUrl("git@github.com:user/repo")).toBeNull(); diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 0f574ba6..1508adcd 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -7,7 +7,6 @@ */ import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -51,6 +50,20 @@ function getFileContent(repoDir: string, filename: string): string { return readFileSync(join(repoDir, filename), "utf-8"); } +type GitSourceForTest = { + type: "git"; + repo: string; + host: string; + path: string; + pinned: boolean; + ref?: string; +}; + +interface PackageManagerPathInternals { + parseSource(source: string): GitSourceForTest; + getGitInstallPath(source: GitSourceForTest, scope: "temporary"): string; +} + describe("DefaultPackageManager git update", () => { let tempDir: string; let remoteDir: string; // Simulates the "remote" repository @@ -282,7 +295,7 @@ describe("DefaultPackageManager git update", () => { }); describe("pinned sources", () => { - it("should not update pinned git sources (with @ref)", async () => { + it("should not move pinned git sources past their configured ref", async () => { // Create remote repo first to get the initial commit mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -301,21 +314,83 @@ describe("DefaultPackageManager git update", () => { // Add new commit to remote createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - // Update should be skipped for pinned sources await packageManager.update(); // Should still be on initial commit expect(getCurrentCommit(installedDir)).toBe(initialCommit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); }); + + it("should checkout the configured pinned git ref during full and targeted updates", async () => { + mkdirSync(remoteDir, { recursive: true }); + initGitRepo(remoteDir); + const v1Commit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + git(["tag", "v1"], remoteDir); + const v2Commit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); + git(["tag", "v2"], remoteDir); + + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["checkout", "v1"], installedDir); + expect(getCurrentCommit(installedDir)).toBe(v1Commit); + + const pinnedSource = `${gitSource}@v2`; + settingsManager.setPackages([pinnedSource]); + + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(v2Commit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + + git(["checkout", "v1"], installedDir); + + await packageManager.update(pinnedSource); + + expect(getCurrentCommit(installedDir)).toBe(v2Commit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + }); + + it("should not reset an annotated tag checkout that already matches the configured ref", async () => { + mkdirSync(remoteDir, { recursive: true }); + initGitRepo(remoteDir); + const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + git(["tag", "-a", "v1", "-m", "v1"], remoteDir); + + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["checkout", "v1"], installedDir); + expect(getCurrentCommit(installedDir)).toBe(taggedCommit); + + settingsManager.setPackages([`${gitSource}@v1`]); + + const executedCommands: string[] = []; + const managerWithInternals = packageManager as unknown as { + runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; + }; + managerWithInternals.runCommand = async (command, args, options) => { + executedCommands.push(`${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { + cwd: options?.cwd, + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); + } + }; + + await packageManager.update(); + + expect(executedCommands).toContain("git fetch origin v1"); + expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false); + expect(executedCommands).not.toContain("git clean -fdx"); + expect(getCurrentCommit(installedDir)).toBe(taggedCommit); + }); }); describe("temporary git sources", () => { it("should refresh cached temporary git sources when resolving", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); @@ -359,10 +434,8 @@ describe("DefaultPackageManager git update", () => { }); it("should not refresh pinned temporary git sources", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); diff --git a/packages/coding-agent/test/http-dispatcher.test.ts b/packages/coding-agent/test/http-dispatcher.test.ts new file mode 100644 index 00000000..f70022f1 --- /dev/null +++ b/packages/coding-agent/test/http-dispatcher.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { applyHttpProxySettings } from "../src/core/http-dispatcher.ts"; + +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const; + +describe("http proxy settings", () => { + let savedEnv: Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>; + + beforeEach(() => { + savedEnv = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) as Record< + (typeof PROXY_ENV_KEYS)[number], + string | undefined + >; + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("applies httpProxy to HTTP_PROXY and HTTPS_PROXY", () => { + applyHttpProxySettings("http://127.0.0.1:7890"); + + expect(process.env.HTTP_PROXY).toBe("http://127.0.0.1:7890"); + expect(process.env.HTTPS_PROXY).toBe("http://127.0.0.1:7890"); + }); + + it("does not override existing proxy env vars", () => { + process.env.HTTP_PROXY = "http://env-http:8080"; + process.env.HTTPS_PROXY = "http://env-https:8080"; + + applyHttpProxySettings("http://settings:7890"); + + expect(process.env.HTTP_PROXY).toBe("http://env-http:8080"); + expect(process.env.HTTPS_PROXY).toBe("http://env-https:8080"); + }); + + it("ignores empty values", () => { + applyHttpProxySettings(" "); + + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts index 40f264a8..13d39dc6 100644 --- a/packages/coding-agent/test/image-processing.test.ts +++ b/packages/coding-agent/test/image-processing.test.ts @@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 = const LARGE_PNG_200x200 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII="; +function imageBytes(base64Data: string): Uint8Array { + return Buffer.from(base64Data, "base64"); +} + describe("convertToPng", () => { it("should return original data for PNG input", async () => { const result = await convertToPng(TINY_PNG, "image/png"); @@ -46,11 +50,28 @@ describe("convertToPng", () => { }); describe("resizeImage", () => { + it("should keep caller input bytes intact", async () => { + const input = new Uint8Array(imageBytes(TINY_PNG)); + const originalByteLength = input.byteLength; + const originalFirstByte = input[0]; + + const result = await resizeImage(input, "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(input.byteLength).toBe(originalByteLength); + expect(input[0]).toBe(originalFirstByte); + }); + it("should return original image if within limits", async () => { - const result = await resizeImage( - { type: "image", data: TINY_PNG, mimeType: "image/png" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_PNG), "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); @@ -62,10 +83,11 @@ describe("resizeImage", () => { }); it("should resize image exceeding dimension limits", async () => { - const result = await resizeImage( - { type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" }, - { maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", { + maxWidth: 50, + maxHeight: 50, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(true); @@ -80,10 +102,11 @@ describe("resizeImage", () => { const originalSize = originalBuffer.length; // Set maxBytes to less than the original encoded image size - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9), + }); // Should have tried to reduce size expect(result).not.toBeNull(); @@ -93,19 +116,21 @@ describe("resizeImage", () => { }); it("should return null when image cannot be resized below maxBytes", async () => { - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: 1 }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: 1, + }); expect(result).toBeNull(); }); it("should handle JPEG input", async () => { - const result = await resizeImage( - { type: "image", data: TINY_JPEG, mimeType: "image/jpeg" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); diff --git a/packages/coding-agent/test/input-transform-streaming-example.test.ts b/packages/coding-agent/test/input-transform-streaming-example.test.ts new file mode 100644 index 00000000..faf95b6b --- /dev/null +++ b/packages/coding-agent/test/input-transform-streaming-example.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import inputTransformStreaming from "../examples/extensions/input-transform-streaming.ts"; +import type { + ExecResult, + ExtensionAPI, + ExtensionContext, + InputEvent, + InputEventResult, +} from "../src/core/extensions/index.ts"; + +type InputHandler = (event: InputEvent, ctx: ExtensionContext) => Promise; + +function setup(execResult: ExecResult) { + let handler: InputHandler | undefined; + + const exec = vi.fn().mockResolvedValue(execResult); + + const api = { + on: (event: string, h: InputHandler) => { + if (event === "input") handler = h; + }, + exec, + } as unknown as ExtensionAPI; + + inputTransformStreaming(api); + + const ctx = {} as ExtensionContext; + + function emit(text: string, streamingBehavior?: "steer" | "followUp") { + return handler!({ type: "input", text, source: "interactive", streamingBehavior }, ctx); + } + + return { emit, exec }; +} + +describe("input-transform-streaming example", () => { + const diffOutput = " src/index.ts | 5 ++---\n 1 file changed, 2 insertions(+), 3 deletions(-)"; + const gitSuccess: ExecResult = { stdout: diffOutput, stderr: "", code: 0, killed: false }; + const gitEmpty: ExecResult = { stdout: "", stderr: "", code: 0, killed: false }; + const gitFail: ExecResult = { stdout: "", stderr: "not a git repo", code: 128, killed: false }; + + it("skips exec during steering", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("what changes did I make?", "steer"); + expect(result).toEqual({ action: "continue" }); + expect(exec).not.toHaveBeenCalled(); + }); + + it("transforms when idle and text matches trigger", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("review my changes"); + expect(exec).toHaveBeenCalledWith("git", ["diff", "--stat"]); + expect(result).toMatchObject({ action: "transform" }); + const text = (result as { text: string }).text; + expect(text).toContain("review my changes"); + expect(text).toContain("src/index.ts"); + }); + + it("transforms when queued as follow-up", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("show me the diff", "followUp"); + expect(exec).toHaveBeenCalled(); + expect(result).toMatchObject({ action: "transform" }); + }); + + it("continues when text does not match trigger", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("explain this function"); + expect(result).toEqual({ action: "continue" }); + expect(exec).not.toHaveBeenCalled(); + }); + + it("continues when git diff is empty", async () => { + const { emit } = setup(gitEmpty); + const result = await emit("any changes?"); + expect(result).toEqual({ action: "continue" }); + }); + + it("continues when git fails", async () => { + const { emit } = setup(gitFail); + const result = await emit("show modified files"); + expect(result).toEqual({ action: "continue" }); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts new file mode 100644 index 00000000..b784f376 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +type SubmitContext = { + defaultEditor: { onSubmit?: (text: string) => void }; + editor: { + addToHistory?: (text: string) => void; + setText: (text: string) => void; + }; + session: { + isCompacting: boolean; + isStreaming: boolean; + isBashRunning: boolean; + prompt: (text: string, options?: unknown) => Promise; + }; + flushPendingBashComponents: () => void; + onInputCallback?: (text: string) => void; + pendingUserInputs: string[]; +}; + +type InputContext = { + onInputCallback?: (text: string) => void; + pendingUserInputs: string[]; +}; + +type InteractiveModePrivate = { + setupEditorSubmitHandler(this: SubmitContext): void; + getUserInput(this: InputContext): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate; + +function createSubmitContext(): SubmitContext { + return { + defaultEditor: {}, + editor: { + addToHistory: vi.fn(), + setText: vi.fn(), + }, + session: { + isCompacting: false, + isStreaming: false, + isBashRunning: false, + prompt: vi.fn(async () => {}), + }, + flushPendingBashComponents: vi.fn(), + pendingUserInputs: [], + }; +} + +describe("InteractiveMode startup input", () => { + it("queues a normal prompt submitted before the input callback is installed", async () => { + const context = createSubmitContext(); + interactiveModePrototype.setupEditorSubmitHandler.call(context); + + await context.defaultEditor.onSubmit?.(" early prompt "); + + expect(context.pendingUserInputs).toEqual(["early prompt"]); + expect(context.flushPendingBashComponents).toHaveBeenCalledTimes(1); + expect(context.editor.addToHistory).toHaveBeenCalledWith("early prompt"); + }); + + it("returns queued startup input before installing a new input callback", async () => { + const context: InputContext = { + pendingUserInputs: ["queued prompt"], + }; + + await expect(interactiveModePrototype.getUserInput.call(context)).resolves.toBe("queued prompt"); + expect(context.onInputCallback).toBeUndefined(); + expect(context.pendingUserInputs).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 7f3ccfe1..94b498fd 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,7 +1,9 @@ import { homedir } from "node:os"; import * as path from "node:path"; -import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui"; +import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; +import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; @@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string { return container.children.flatMap((child) => child.render(width)).join("\n"); } +class TestFocusableComponent implements Component, Focusable { + focused = false; + inputs: string[] = []; + private readonly label: string; + private text = ""; + + constructor(label: string) { + this.label = label; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return [this.label]; + } + + invalidate(): void {} +} + +async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + function normalizeRenderedOutput(container: Container, width = 220): string { return renderAll(container, width) .replace(/\u001b\[[0-9;]*m/g, "") @@ -114,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const fakeThis: any = { session: { settingsManager }, settingsManager, + themeController: { + setThemeInstance: vi.fn(() => ({ success: true })), + setThemeName: vi.fn(() => { + fakeThis.ui.requestRender(); + return { success: true }; + }), + }, ui: { requestRender: vi.fn() }, }; @@ -121,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const result = uiContext.setTheme("light"); expect(result.success).toBe(true); + expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light"); expect(settingsManager.setTheme).toHaveBeenCalledWith("light"); expect(currentTheme).toBe("light"); expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1); @@ -136,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const fakeThis: any = { session: { settingsManager }, settingsManager, + themeController: { + setThemeInstance: vi.fn(() => ({ success: true })), + setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })), + }, ui: { requestRender: vi.fn() }, }; @@ -143,11 +192,84 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const result = uiContext.setTheme("__missing_theme__"); expect(result.success).toBe(false); + expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__"); expect(settingsManager.setTheme).not.toHaveBeenCalled(); expect(fakeThis.ui.requestRender).not.toHaveBeenCalled(); }); }); +describe("InteractiveMode.showExtensionCustom", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => { + const terminal = new VirtualTerminal(80, 24); + const ui = new TUI(terminal); + const editorContainer = new Container(); + const editor = new TestFocusableComponent("EDITOR"); + const palette = new TestFocusableComponent("PALETTE"); + const overlay = new TestFocusableComponent("OVERLAY"); + const replacement = new TestFocusableComponent("REPLACEMENT"); + let closeOverlay: (value: string) => void = () => { + throw new Error("closeOverlay was not initialized"); + }; + let closeReplacement: (value: string) => void = () => { + throw new Error("closeReplacement was not initialized"); + }; + const fakeThis = { + editor, + editorContainer, + keybindings: {}, + ui, + }; + const showExtensionCustom = ( + factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, + ): Promise => + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise; + + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.addChild(palette); + ui.setFocus(palette); + ui.start(); + try { + const overlayPromise = showExtensionCustom( + (_tui, _theme, _keybindings, done) => { + closeOverlay = done; + return overlay; + }, + { overlay: true }, + ); + await flushTui(ui, terminal); + expect(overlay.focused).toBe(true); + + const replacementPromise = showExtensionCustom((_tui, _theme, _keybindings, done) => { + closeReplacement = done; + return replacement; + }); + await flushTui(ui, terminal); + expect(replacement.focused).toBe(true); + + closeReplacement("done"); + await replacementPromise; + await flushTui(ui, terminal); + terminal.sendInput("x"); + await flushTui(ui, terminal); + + expect(overlay.inputs).toEqual(["x"]); + expect(editor.inputs).toEqual([]); + expect(overlay.focused).toBe(true); + + closeOverlay("closed"); + await overlayPromise; + } finally { + ui.stop(); + } + }); +}); + describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { test("stores wrapper factories and rebuilds autocomplete immediately", () => { const wrapper: AutocompleteProviderFactory = (current) => current; @@ -215,6 +337,89 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); }); + + test("merges triggerCharacters from wrapper factories", () => { + const defaultEditor = { setAutocompleteProvider: vi.fn() }; + const customEditor = { setAutocompleteProvider: vi.fn() }; + const passThrough = + (triggerCharacters: string[]): AutocompleteProviderFactory => + (current) => ({ + triggerCharacters, + getSuggestions: (lines, cursorLine, cursorCol, options) => + current.getSuggestions(lines, cursorLine, cursorCol, options), + applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => + current.applyCompletion(lines, cursorLine, cursorCol, item, prefix), + }); + + const fakeThis = { + createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), + defaultEditor, + editor: customEditor, + autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])], + }; + + ( + InteractiveMode as unknown as { + prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void }; + } + ).prototype.setupAutocompleteProvider.call(fakeThis); + + const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; + expect(provider.triggerCharacters).toEqual(["$", "!"]); + }); +}); + +describe("InteractiveMode.createBaseAutocompleteProvider", () => { + test("matches model command arguments across provider/model order", async () => { + type TestModel = { id: string; provider: string; name: string }; + type FakeInteractiveMode = { + session: { + scopedModels: Array<{ model: TestModel }>; + modelRegistry: { getAvailable: () => TestModel[] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const models = [ + { id: "gpt-5.2-codex", provider: "github-copilot", name: "GPT-5.2 Codex" }, + { id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }, + ]; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRegistry: { getAvailable: () => models }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/model codexgpt"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.items.map((item) => item.value)).toEqual([ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.2-codex", + ]); + }); }); describe("InteractiveMode.showLoadedResources", () => { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index ad706ab5..1479808a 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai"; import { getApiProvider } from "@earendil-works/pi-ai"; import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; @@ -25,6 +25,7 @@ describe("ModelRegistry", () => { rmSync(tempDir, { recursive: true }); } clearApiKeyCache(); + vi.restoreAllMocks(); }); /** Create minimal provider config */ @@ -35,7 +36,7 @@ describe("ModelRegistry", () => { ): ProviderConfigInput { return { baseUrl, - apiKey: "TEST_KEY", + apiKey: "test-key", api: api as Api, models: models.map((m) => ({ id: m.id, @@ -433,6 +434,43 @@ describe("ModelRegistry", () => { expect(compat?.cacheControlFormat).toBe("anthropic"); }); + test("compat schema accepts chat template thinking configuration", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com/v1", + apiKey: "DEMO_KEY", + api: "openai-completions", + models: [ + { + id: "demo-model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + compat: { + thinkingFormat: "chat-template", + chatTemplateKwargs: { + preserve_thinking: true, + thinking: { $var: "thinking.enabled" }, + }, + }, + }, + ], + }, + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined; + + expect(registry.getError()).toBeUndefined(); + expect(compat?.thinkingFormat).toBe("chat-template"); + expect(compat?.chatTemplateKwargs).toEqual({ + preserve_thinking: true, + thinking: { $var: "thinking.enabled" }, + }); + }); + test("compat schema accepts Anthropic eager tool input streaming flag", () => { writeRawModelsJson({ demo: { @@ -852,7 +890,7 @@ describe("ModelRegistry", () => { registry.registerProvider("named-provider", { name: "Named Provider", baseUrl: "https://provider.test/v1", - apiKey: "TEST_KEY", + apiKey: "test-key", api: "openai-completions", models: [ { @@ -892,6 +930,91 @@ describe("ModelRegistry", () => { expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider"); }); + test("stored API key env propagates to request auth and resolves headers", async () => { + authStorage.set("cloudflare-ai-gateway", { + type: "api_key", + key: "$CLOUDFLARE_API_KEY", + env: { + CLOUDFLARE_API_KEY: "stored-cf-token", + CLOUDFLARE_ACCOUNT_ID: "stored-account", + }, + }); + writeRawModelsJson({ + "cloudflare-ai-gateway": { + headers: { "x-account": "$CLOUDFLARE_ACCOUNT_ID" }, + }, + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const model = registry.getAll().find((m) => m.provider === "cloudflare-ai-gateway"); + expect(model).toBeDefined(); + + const auth = await registry.getApiKeyAndHeaders(model!); + + expect(auth).toEqual({ + ok: true, + apiKey: "stored-cf-token", + headers: { "x-account": "stored-account" }, + env: { + CLOUDFLARE_API_KEY: "stored-cf-token", + CLOUDFLARE_ACCOUNT_ID: "stored-account", + }, + }); + }); + + test("registerProvider treats uppercase apiKey and headers as literals", async () => { + const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + registry.registerProvider("literal-provider", { + ...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"), + apiKey: "CUSTOM_NAME", + headers: { Authorization: "BEARER" }, + models: [ + { + id: "demo-model", + name: "demo-model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 8000, + headers: { "x-model-token": "MODEL_TOKEN" }, + }, + ], + }); + + expect(await registry.getApiKeyForProvider("literal-provider")).toBe("CUSTOM_NAME"); + const model = registry.find("literal-provider", "demo-model"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_NAME", + headers: { + Authorization: "BEARER", + "x-model-token": "MODEL_TOKEN", + }, + }); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + } + }); + test("failed registerProvider does not persist invalid streamSimple config", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); @@ -911,7 +1034,7 @@ describe("ModelRegistry", () => { registry.registerProvider("demo-provider", { baseUrl: "https://provider.test/v1", - apiKey: "TEST_KEY", + apiKey: "test-key", api: "openai-completions", models: [ { @@ -931,7 +1054,7 @@ describe("ModelRegistry", () => { expect(() => registry.registerProvider("demo-provider", { baseUrl: "https://provider.test/v2", - apiKey: "TEST_KEY", + apiKey: "test-key", models: [ { id: "broken-model", @@ -1187,7 +1310,117 @@ describe("ModelRegistry", () => { expect(apiKey).toBeUndefined(); }); - test("apiKey as environment variable name resolves to env value", async () => { + test("apiKey with $ prefix resolves to env value", async () => { + const originalEnv = process.env.TEST_API_KEY_12345; + process.env.TEST_API_KEY_12345 = "env-api-key-value"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_API_KEY_12345; + } else { + process.env.TEST_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey with braced env syntax resolves to env value", async () => { + const originalEnv = process.env.TEST_BRACED_API_KEY_12345; + process.env.TEST_BRACED_API_KEY_12345 = "braced-env-api-key-value"; + const bracedKey = "$" + "{TEST_BRACED_API_KEY_12345}"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(bracedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("braced-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_BRACED_API_KEY_12345; + } else { + process.env.TEST_BRACED_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey interpolates braced env references inside literals", async () => { + const originalPartA = process.env.TEST_INTERPOLATED_PART_A_12345; + const originalPartB = process.env.TEST_INTERPOLATED_PART_B_12345; + process.env.TEST_INTERPOLATED_PART_A_12345 = "left"; + process.env.TEST_INTERPOLATED_PART_B_12345 = "right"; + const interpolatedKey = ["$", "{TEST_INTERPOLATED_PART_A_12345}_$", "{TEST_INTERPOLATED_PART_B_12345}"].join( + "", + ); + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(interpolatedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("left_right"); + } finally { + if (originalPartA === undefined) { + delete process.env.TEST_INTERPOLATED_PART_A_12345; + } else { + process.env.TEST_INTERPOLATED_PART_A_12345 = originalPartA; + } + if (originalPartB === undefined) { + delete process.env.TEST_INTERPOLATED_PART_B_12345; + } else { + process.env.TEST_INTERPOLATED_PART_B_12345 = originalPartB; + } + } + }); + + test("apiKey with $$ prefix escapes a leading dollar", async () => { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("$TEST_API_KEY_12345"); + }); + + test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => { + const originalEnv = process.env.TEST_API_KEY_12345; + process.env.TEST_API_KEY_12345 = "env-api-key-value"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$!literal-$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("!literal-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_API_KEY_12345; + } else { + process.env.TEST_API_KEY_12345 = originalEnv; + } + } + }); + + test("plain apiKey is used directly even when it matches an env var", async () => { const originalEnv = process.env.TEST_API_KEY_12345; process.env.TEST_API_KEY_12345 = "env-api-key-value"; @@ -1199,7 +1432,7 @@ describe("ModelRegistry", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); const apiKey = await registry.getApiKeyForProvider("custom-provider"); - expect(apiKey).toBe("env-api-key-value"); + expect(apiKey).toBe("TEST_API_KEY_12345"); } finally { if (originalEnv === undefined) { delete process.env.TEST_API_KEY_12345; @@ -1318,7 +1551,7 @@ describe("ModelRegistry", () => { process.env[envVarName] = "status-test-key"; writeRawModelsJson({ - "custom-provider": providerWithApiKey(envVarName), + "custom-provider": providerWithApiKey(`$${envVarName}`), }); const registry = ModelRegistry.create(authStorage, modelsJsonPath); @@ -1337,6 +1570,41 @@ describe("ModelRegistry", () => { } }); + test("provider auth status reports interpolated apiKey environment variables", () => { + const envVarNameA = "TEST_API_KEY_STATUS_PART_A_98765"; + const envVarNameB = "TEST_API_KEY_STATUS_PART_B_98765"; + const originalEnvA = process.env[envVarNameA]; + const originalEnvB = process.env[envVarNameB]; + process.env[envVarNameA] = "left"; + process.env[envVarNameB] = "right"; + const interpolatedKey = ["$", "{", envVarNameA, "}_$", "{", envVarNameB, "}"].join(""); + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(interpolatedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ + configured: true, + source: "environment", + label: `${envVarNameA}, ${envVarNameB}`, + }); + } finally { + if (originalEnvA === undefined) { + delete process.env[envVarNameA]; + } else { + process.env[envVarNameA] = originalEnvA; + } + if (originalEnvB === undefined) { + delete process.env[envVarNameB]; + } else { + process.env[envVarNameB] = originalEnvB; + } + } + }); + test("provider auth status reports non-env apiKey values from models.json as a config key", () => { writeRawModelsJson({ "custom-provider": providerWithApiKey("literal_api_key_value"), @@ -1350,6 +1618,29 @@ describe("ModelRegistry", () => { }); }); + test("missing explicit env apiKey keeps provider unavailable", () => { + const envVarName = "TEST_API_KEY_MISSING_TEST_98765"; + const originalEnv = process.env[envVarName]; + delete process.env[envVarName]; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(`$${envVarName}`), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ configured: false }); + expect(registry.getAvailable().some((model) => model.provider === "custom-provider")).toBe(false); + } finally { + if (originalEnv === undefined) { + delete process.env[envVarName]; + } else { + process.env[envVarName] = originalEnv; + } + } + }); + test("provider auth status reports command apiKey values from models.json without executing them", () => { const counterFile = join(tempDir, "status-counter"); writeFileSync(counterFile, "0"); @@ -1376,7 +1667,7 @@ describe("ModelRegistry", () => { process.env[envVarName] = "first-value"; writeRawModelsJson({ - "custom-provider": providerWithApiKey(envVarName), + "custom-provider": providerWithApiKey(`$${envVarName}`), }); const registry = ModelRegistry.create(authStorage, modelsJsonPath); @@ -1415,6 +1706,25 @@ describe("ModelRegistry", () => { expect(count).toBe(0); }); + test("getAvailable filters GitHub Copilot OAuth models to account picker availability", () => { + authStorage.set("github-copilot", { + type: "oauth", + refresh: "github-access-token", + access: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires: Date.now() + 60_000, + availableModelIds: ["gpt-4.1"], + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect( + registry + .getAvailable() + .filter((m) => m.provider === "github-copilot") + .map((m) => m.id), + ).toEqual(["gpt-4.1"]); + }); + test("getApiKeyAndHeaders resolves authHeader on every request", async () => { const tokenFile = join(tempDir, "token"); writeFileSync(tokenFile, "token-1"); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 7339200b..163c3ab5 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -344,6 +344,7 @@ describe("resolveCliModel", () => { }; const registry = { getAll: () => [...allModels, zaiModel, gatewayModel], + hasConfiguredAuth: () => true, } as unknown as Parameters[0]["modelRegistry"]; const result = resolveCliModel({ @@ -356,6 +357,46 @@ describe("resolveCliModel", () => { expect(result.model?.id).toBe("glm-5"); }); + test("prefers an authenticated exact raw model id over an unauthenticated inferred provider", () => { + const commandcodeModel: Model<"anthropic-messages"> = { + id: "xiaomi/mimo-v2.5-pro", + name: "Xiaomi MiMo via Commandcode", + api: "anthropic-messages", + provider: "commandcode", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const xiaomiModel: Model<"anthropic-messages"> = { + id: "mimo-v2.5-pro", + name: "Xiaomi MiMo", + api: "anthropic-messages", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const registry = { + getAll: () => [...allModels, commandcodeModel, xiaomiModel], + hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "commandcode", + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "xiaomi/mimo-v2.5-pro", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("commandcode"); + expect(result.model?.id).toBe("xiaomi/mimo-v2.5-pro"); + }); + test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => { const registry = { getAll: () => allModels, @@ -370,6 +411,128 @@ describe("resolveCliModel", () => { expect(result.model?.provider).toBe("openrouter"); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); }); + + describe("custom model fallback with :thinking suffix (#5552)", () => { + // Models for a provider that has registered models but the specific model ID + // is not in the registry (triggers buildFallbackModel path). + const neuralwattModel: Model<"anthropic-messages"> = { + id: "some-base-model", + name: "Some Base Model", + api: "anthropic-messages", + provider: "neuralwatt", + baseUrl: "https://api.neuralwatt.com", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + + const modelsWithNeuralwatt = [...allModels, neuralwattModel]; + + test("strips :thinking suffix from custom model id in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // The :high suffix must NOT leak into the model id sent to the API + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.model?.reasoning).toBe(true); + expect(result.thinkingLevel).toBe("high"); + }); + + test("custom model without thinking suffix works normally in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("all valid thinking levels work in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) { + const result = resolveCliModel({ + cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`, + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe(level); + } + }); + + test("invalid thinking suffix on custom model is treated as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:banana", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // Invalid suffix stays in the id (it's not a thinking level) + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:banana"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("explicit --provider with custom model:thinking strips suffix correctly", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliProvider: "neuralwatt", + cliModel: "zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe("high"); + }); + + test("with explicit --thinking, :suffix is kept as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + cliThinking: "medium", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // :high is kept as part of the model id since --thinking was explicit + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:high"); + expect(result.thinkingLevel).toBeUndefined(); + }); + }); }); describe("default model selection", () => { @@ -378,11 +541,12 @@ describe("default model selection", () => { expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.5"); }); - test("zai, minimax, and cerebras defaults track current models", () => { + test("zai, minimax, cerebras, and ant-ling defaults track current models", () => { expect(defaultModelPerProvider.zai).toBe("glm-5.1"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7"); + expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T"); }); test("ai-gateway default tracks current model", () => { diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 5e4a1a48..df87cf34 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,9 +1,11 @@ -import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; +import { handlePackageCommand } from "../src/package-manager-cli.ts"; describe("package commands", () => { let tempDir: string; @@ -21,6 +23,10 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + async function runPackageCommandDirectly(args: string[]): Promise { + expect(await handlePackageCommand(args)).toBe(true); + } + beforeEach(() => { tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`); agentDir = join(tempDir, "agent"); @@ -36,12 +42,21 @@ describe("package commands", () => { originalExitCode = process.exitCode; originalExecPath = process.execPath; process.exitCode = undefined; + vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { + if (code === undefined || code === null || Number(code) === 0) { + process.exitCode = undefined; + } else { + process.exitCode = code; + } + return undefined as never; + }) as typeof process.exit); process.env[ENV_AGENT_DIR] = agentDir; process.chdir(projectDir); }); afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; if (originalAgentDir === undefined) { @@ -85,6 +100,231 @@ describe("package commands", () => { expect(removedSettings.packages ?? []).toHaveLength(0); }); + it("skips untrusted project package settings", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses remembered project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("overrides remembered trust for list with --no-approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--no-approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("approves project trust for list with --approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses default project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses project_trust extensions for package commands", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["list"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => ({ trusted: "yes" })); + }, + ], + }), + ).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("does not prompt or ask extensions for project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + const fakeNpmPath = join(tempDir, "fake-project-npm.cjs"); + const recordPath = join(tempDir, "project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + let projectTrustCalled = false; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["update", "--extensions"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => { + projectTrustCalled = true; + return { trusted: "yes" }; + }); + }, + ], + }), + ).resolves.toBeUndefined(); + + expect(projectTrustCalled).toBe(false); + expect(existsSync(recordPath)).toBe(false); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses saved project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs"); + const recordPath = join(tempDir, "trusted-project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["update", "--extensions"])).resolves.toBeUndefined(); + + expect(existsSync(recordPath)).toBe(true); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("lets trust.json override default project trust", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("blocks local package changes when project is untrusted", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), "{}"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config."); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("allows local package install to initialize fresh project settings", async () => { + await main(["install", "-l", packageDir]); + + const settingsPath = join(projectDir, ".pi", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; + expect(settings.packages?.length).toBe(1); + const stored = settings.packages?.[0] ?? ""; + expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir)); + expect(process.exitCode).toBeUndefined(); + }); + it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -111,7 +351,7 @@ describe("package commands", () => { const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain('Unknown option --unknown for "install".'); - expect(stderr).toContain('Use "pi --help" or "pi install [-l]".'); + expect(stderr).toContain('Use "pi --help" or "pi install [-l] [--approve|--no-approve]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); @@ -169,7 +409,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -213,7 +453,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -262,7 +502,7 @@ else { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -315,7 +555,7 @@ if(args.includes("install")) process.exit(23); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBe(1); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 30068da1..9b1b0d7f 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; @@ -33,6 +33,20 @@ interface PackageManagerInternals { options?: { cwd?: string; timeoutMs?: number; env?: Record }, ): Promise; getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; + parseSource( + source: string, + ): + | { type: "npm"; spec: string; name: string; pinned: boolean } + | { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string } + | { type: "local"; path: string }; + getNpmInstallPath( + source: { type: "npm"; spec: string; name: string; pinned: boolean }, + scope: "user" | "project" | "temporary", + ): string; + getGitInstallPath( + source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string }, + scope: "user" | "project" | "temporary", + ): string; } // Helper to check if a resource is enabled @@ -693,7 +707,17 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith( "mise", - ["exec", "node@20", "--", "npm", "install", "@scope/pkg", "--prefix", join(agentDir, "npm")], + [ + "exec", + "node@20", + "--", + "npm", + "install", + "@scope/pkg", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ], undefined, ); }); @@ -714,7 +738,7 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith( "mise", - ["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm")], + ["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm"), "--omit=peer"], undefined, ); }); @@ -748,7 +772,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "HEAD") { return "old-head"; } - if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") { + if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD^{commit}") { return "new-head"; } throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); @@ -758,7 +782,9 @@ Content`, await packageManager.install(source); expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir }); - expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD^{commit}"], { + cwd: targetDir, + }); expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); }); @@ -779,7 +805,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "HEAD") { return "old-head"; } - if (args[0] === "rev-parse" && args[1] === "origin/HEAD") { + if (args[0] === "rev-parse" && args[1] === "origin/HEAD^{commit}") { return "new-head"; } throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); @@ -789,7 +815,9 @@ Content`, await packageManager.install(source); expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir }); - expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD^{commit}"], { + cwd: targetDir, + }); expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); }); @@ -832,7 +860,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") { return "origin/main"; } - if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) { return "remote-head"; } if (args[0] === "rev-parse" && args[1] === "HEAD") { @@ -868,7 +896,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") { return "origin/main"; } - if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) { return "remote-head"; } if (args[0] === "rev-parse" && args[1] === "HEAD") { @@ -949,6 +977,8 @@ Content`, "pnpm-pkg", "--prefix", join(agentDir, "npm"), + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", "--config.strict-dep-builds=false", ]); mkdirSync(join(packagePath, "extensions"), { recursive: true }); @@ -1098,8 +1128,17 @@ Content`, }); it("should parse package source types from docs examples", () => { - expect((packageManager as any).parseSource("npm:@scope/pkg@1.2.3").type).toBe("npm"); - expect((packageManager as any).parseSource("npm:pkg").type).toBe("npm"); + const parseNpm = (source: string) => { + const parsed = (packageManager as any).parseSource(source); + if (parsed.type !== "npm") { + throw new Error(`Expected npm source: ${source}`); + } + return parsed; + }; + + expect(parseNpm("npm:@scope/pkg@1.2.3").pinned).toBe(true); + expect(parseNpm("npm:@scope/pkg@^1.2.3").pinned).toBe(false); + expect(parseNpm("npm:pkg").pinned).toBe(false); expect((packageManager as any).parseSource("git:github.com/user/repo@v1").type).toBe("git"); expect((packageManager as any).parseSource("https://github.com/user/repo@v1").type).toBe("git"); @@ -1122,6 +1161,45 @@ Content`, }); }); + describe("git install paths", () => { + it("should reject paths outside git install roots", () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const traversalSource = { + type: "git" as const, + repo: "git@evil.example:../../victim/repo", + host: "evil.example", + path: "../../victim/repo", + pinned: false, + }; + + for (const scope of ["user", "project", "temporary"] as const) { + expect(() => managerWithInternals.getGitInstallPath(traversalSource, scope)).toThrow( + "outside package install root", + ); + } + }); + }); + + describe("temporary install paths", () => { + it("should place temporary npm packages under the agent temp extension folder", () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const source = managerWithInternals.parseSource("npm:left-pad"); + if (source.type !== "npm") { + throw new Error("Expected npm source"); + } + + const installPath = managerWithInternals.getNpmInstallPath(source, "temporary"); + const tempRoot = join(agentDir, "tmp", "extensions"); + + expect(pathEndsWith(installPath, "node_modules/left-pad")).toBe(true); + expect(relative(tempRoot, installPath).startsWith("..")).toBe(false); + expect(installPath.startsWith(join(tmpdir(), "pi-extensions"))).toBe(false); + if (process.platform !== "win32") { + expect(statSync(tempRoot).mode & 0o777).toBe(0o700); + } + }); + }); + describe("settings source normalization", () => { it("should store global local packages relative to agent settings base", () => { const pkgDir = join(tempDir, "packages", "local-global-pkg"); @@ -1983,25 +2061,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); describe("offline mode and network timeouts", () => { - it("should update project npm packages using @latest when newer version is available", async () => { + it("should update npm range packages using the configured spec", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); - settingsManager.setProjectPackages(["npm:example"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.2.0"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).toHaveBeenCalledWith( "npm", - ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm")], + ["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], undefined, ); }); @@ -2009,17 +2089,19 @@ export default function(api) { api.registerTool({ name: "test", description: "te it("should skip project npm update when installed version matches latest", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); - writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" })); - settingsManager.setProjectPackages(["npm:example"]); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.3.1" })); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.3.1","1.0.2"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).not.toHaveBeenCalled(); @@ -2040,7 +2122,13 @@ export default function(api) { api.registerTool({ name: "test", description: "te .mockImplementation(async (...callArgs: unknown[]) => { const [command, args] = callArgs as [string, string[]]; expect(command).toBe("npm"); - expect(args).toEqual(["install", "legacy-pkg@latest", "--prefix", join(agentDir, "npm")]); + expect(args).toEqual([ + "install", + "legacy-pkg@latest", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ]); mkdirSync(managedPath, { recursive: true }); writeFileSync( join(managedPath, "package.json"), @@ -2057,7 +2145,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(managedPath); }); - it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => { + it("should batch npm updates per scope and run git updates in parallel while skipping pinned npm and current packages", async () => { const userOldPath = join(agentDir, "npm", "node_modules", "user-old"); const userCurrentPath = join(agentDir, "npm", "node_modules", "user-current"); const userUnknownPath = join(agentDir, "npm", "node_modules", "user-unknown"); @@ -2150,16 +2238,30 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).toHaveBeenNthCalledWith( 1, "npm", - ["install", "user-old@latest", "user-unknown@latest", "--prefix", join(agentDir, "npm")], + [ + "install", + "user-old@latest", + "user-unknown@latest", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ], undefined, ); expect(runCommandSpy).toHaveBeenNthCalledWith( 2, "npm", - ["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")], + [ + "install", + "project-old@latest", + "project-missing@latest", + "--prefix", + join(tempDir, ".pi", "npm"), + "--legacy-peer-deps", + ], undefined, ); - expect(updateGitSpy).toHaveBeenCalledTimes(3); + expect(updateGitSpy).toHaveBeenCalledTimes(4); expect(maxConcurrentNpmUpdates).toBeGreaterThan(1); expect(maxConcurrentGitUpdates).toBeGreaterThan(1); }); @@ -2209,11 +2311,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); it("should not run npm view during resolve for installed unpinned packages", async () => { + process.env.PI_OFFLINE = "1"; const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(join(installedPath, "extensions"), { recursive: true }); writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};"); - settingsManager.setProjectPackages(["npm:example"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 1e4bcf5c..681a7c62 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -190,6 +190,52 @@ describe("substituteArgs", () => { }); }); +// ============================================================================ +// substituteArgs - Positional Defaults +// ============================================================================ + +describe("substituteArgs - positional defaults", () => { + test("should use default when positional arg is missing", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, [])).toBe("List exactly 7 next steps"); + }); + + test("should use positional arg when present", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, ["3"])).toBe("List exactly 3 next steps"); + }); + + test("should use default when positional arg is empty", () => { + expect(substituteArgs(`Mode: \${1:-brief}`, [""])).toBe("Mode: brief"); + }); + + test("should support multiple positional defaults", () => { + expect(substituteArgs(`\${1:-7} \${2:-brief}`, [])).toBe("7 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3"])).toBe("3 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3", "verbose"])).toBe("3 verbose"); + }); + + test("should not recursively substitute patterns in arg values", () => { + expect(substituteArgs(`\${1:-7}`, ["$ARGUMENTS"])).toBe("$ARGUMENTS"); + expect(substituteArgs(`\${1:-7}`, ["$1"])).toBe("$1"); + }); + + test("should not recursively substitute patterns in default values", () => { + expect(substituteArgs(`\${1:-$ARGUMENTS}`, ["a", "b"])).toBe("a"); + expect(substituteArgs(`\${3:-$ARGUMENTS}`, ["a", "b"])).toBe("$ARGUMENTS"); + }); + + test("should support defaults with spaces", () => { + expect(substituteArgs(`\${1:-seven steps}`, [])).toBe("seven steps"); + }); + + test("should support out-of-range positional defaults", () => { + expect(substituteArgs(`\${3:-fallback}`, ["a", "b"])).toBe("fallback"); + }); + + test("should mix positional defaults with existing placeholders", () => { + expect(substituteArgs(`$1 \${2:-x} $ARGUMENTS`, ["a"])).toBe("a x a"); + }); +}); + // ============================================================================ // substituteArgs - Array Slicing (Bash-Style) // ============================================================================ diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 693f05c9..72cee79a 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -187,6 +187,53 @@ Project skill`, expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts")); }); + it("should load user extensions before trust and reuse them after trust resolves", async () => { + const userExtDir = join(agentDir, "extensions"); + const projectExtDir = join(cwd, ".pi", "extensions"); + mkdirSync(userExtDir, { recursive: true }); + mkdirSync(projectExtDir, { recursive: true }); + const loadCountKey = `__piTrustPreloadCount_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const globalState = globalThis as typeof globalThis & Record; + + writeFileSync( + join(userExtDir, "user.ts"), + `globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1; +export default function(pi) { + pi.on("project_trust", () => ({ trusted: "yes" })); + pi.registerCommand("user-trust", { + description: "user trust", + handler: async () => {}, + }); +}`, + ); + writeFileSync( + join(projectExtDir, "project.ts"), + `export default function(pi) { + pi.registerCommand("project-trusted", { + description: "project trusted", + handler: async () => {}, + }); +}`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload({ + resolveProjectTrust: async ({ extensionsResult }) => { + expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([ + join(userExtDir, "user.ts"), + ]); + return true; + }, + }); + + const extensionsResult = loader.getExtensions(); + expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([ + join(cwd, ".pi", "extensions", "project.ts"), + join(userExtDir, "user.ts"), + ]); + expect(globalState[loadCountKey]).toBe(1); + }); + it("should keep both extensions loaded when command names collide", async () => { const userExtDir = join(agentDir, "extensions"); const projectExtDir = join(cwd, ".pi", "extensions"); @@ -329,6 +376,52 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); + it("should skip project resources that require trust when project is not trusted", async () => { + const piDir = join(cwd, ".pi"); + const extensionsDir = join(piDir, "extensions"); + const skillDir = join(piDir, "skills", "project-skill"); + const promptsDir = join(piDir, "prompts"); + const themesDir = join(piDir, "themes"); + mkdirSync(extensionsDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); + mkdirSync(promptsDir, { recursive: true }); + mkdirSync(themesDir, { recursive: true }); + writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt."); + writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt."); + writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions"); + writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); + writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`); + writeFileSync( + join(skillDir, "SKILL.md"), + `--- +name: project-skill +description: Project skill +--- +Project skill content`, + ); + writeFileSync(join(promptsDir, "project.md"), "Project prompt"); + const themeData = JSON.parse( + readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), + ) as { name: string }; + themeData.name = "project-theme"; + writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2)); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("Global system prompt."); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe( + true, + ); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true); + expect(loader.getExtensions().extensions).toHaveLength(0); + expect(loader.getExtensions().errors).toEqual([]); + expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); + expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false); + expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false); + }); + it("should discover APPEND_SYSTEM.md", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true }); diff --git a/packages/coding-agent/test/rpc-client-process-exit.test.ts b/packages/coding-agent/test/rpc-client-process-exit.test.ts new file mode 100644 index 00000000..f023849e --- /dev/null +++ b/packages/coding-agent/test/rpc-client-process-exit.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; + +const tempDirs: string[] = []; + +function writeChildScript(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "pi-rpc-client-exit-")); + tempDirs.push(dir); + const path = join(dir, "child.mjs"); + writeFileSync(path, contents); + return path; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("RpcClient child process failures", () => { + test("rejects an in-flight request when the child process exits", async () => { + const client = new RpcClient({ + cliPath: writeChildScript(` +process.stdin.once("data", () => { + process.exit(43); +}); +process.stdin.resume(); +`), + }); + + await client.start(); + + await expect(client.getCommands()).rejects.toThrow(/Agent process exited \(code=43 signal=null\)/); + }); +}); diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 1504a622..08b3a56f 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -25,7 +25,9 @@ const rpcIo = vi.hoisted(() => ({ })); vi.mock("../src/core/output-guard.js", () => ({ + flushRawStdout: vi.fn(async () => {}), takeOverStdout: vi.fn(), + waitForRawStdoutBackpressure: vi.fn(async () => {}), writeRawStdout: (line: string) => { rpcIo.outputLines.push(line); }, diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 70bb8709..b03e5bb6 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -15,14 +15,14 @@ import { createAgentSession } from "../src/core/sdk.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -describe("createAgentSession OpenRouter attribution headers", () => { +describe("createAgentSession provider attribution headers", () => { let tempDir: string; let cwd: string; let agentDir: string; let originalTelemetryEnv: string | undefined; beforeEach(() => { - tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); cwd = join(tempDir, "project"); agentDir = join(tempDir, "agent"); mkdirSync(cwd, { recursive: true }); @@ -42,9 +42,9 @@ describe("createAgentSession OpenRouter attribution headers", () => { } }); - function createModel(provider: string, baseUrl: string): Model { + function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model { return { - id: `${provider}-test-model`, + id, name: `${provider} Test Model`, api: "openai-completions", provider, @@ -86,6 +86,7 @@ describe("createAgentSession OpenRouter attribution headers", () => { telemetryEnabled?: boolean; providerHeaders?: Record; requestHeaders?: Record; + sessionId?: string; } = {}, ): Promise | undefined> { const settingsManager = SettingsManager.create(cwd, agentDir); @@ -112,6 +113,11 @@ describe("createAgentSession OpenRouter attribution headers", () => { registeredProviders.push(model.provider); } + const sessionManager = SessionManager.inMemory(cwd); + if (options.sessionId) { + sessionManager.newSession({ id: options.sessionId }); + } + const { session } = await createAgentSession({ cwd, agentDir, @@ -119,14 +125,17 @@ describe("createAgentSession OpenRouter attribution headers", () => { authStorage, modelRegistry, settingsManager, - sessionManager: SessionManager.inMemory(cwd), + sessionManager, }); try { await session.agent.streamFn( model, { messages: [] }, - options.requestHeaders ? { headers: options.requestHeaders } : undefined, + { + sessionId: session.sessionId, + ...(options.requestHeaders ? { headers: options.requestHeaders } : {}), + }, ); return capturedOptions?.headers; } finally { @@ -163,6 +172,14 @@ describe("createAgentSession OpenRouter attribution headers", () => { expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); }); + it("preserves legacy OpenRouter base URL substring attribution matching", async () => { + const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai")); + + expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev"); + expect(headers?.["X-OpenRouter-Title"]).toBe("pi"); + expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); + }); + it("lets provider and request headers override the defaults", async () => { const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), { providerHeaders: { @@ -178,4 +195,83 @@ describe("createAgentSession OpenRouter attribution headers", () => { expect(headers?.["X-OpenRouter-Title"]).toBe("request-title"); expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category"); }); + + it("adds default attribution headers for Vercel AI Gateway models", async () => { + const headers = await captureHeaders(createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1")); + + expect(headers?.["http-referer"]).toBe("https://pi.dev"); + expect(headers?.["x-title"]).toBe("pi"); + }); + + it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => { + const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1")); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi"); + }); + + it("adds default attribution headers for the NVIDIA provider", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1")); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi"); + }); + + it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), { + telemetryEnabled: false, + }); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + + it("lets provider and request headers override NVIDIA NIM defaults", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), { + providerHeaders: { + "X-BILLING-INVOKE-ORIGIN": "Provider", + }, + requestHeaders: { + "X-BILLING-INVOKE-ORIGIN": "Request", + }, + }); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request"); + }); + + it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => { + const headers = await captureHeaders( + createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"), + ); + + expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev"); + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + + it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => { + const headers = await captureHeaders( + createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"), + ); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + + it("adds OpenCode session headers", async () => { + const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), { + sessionId: "opencode-session", + }); + + expect(headers?.["x-opencode-session"]).toBe("opencode-session"); + expect(headers?.["x-opencode-client"]).toBe("pi"); + }); + + it("lets configured OpenCode headers override the defaults", async () => { + const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), { + sessionId: "opencode-session", + providerHeaders: { + "x-opencode-session": "configured-session", + "x-opencode-client": "configured-client", + }, + }); + + expect(headers?.["x-opencode-session"]).toBe("configured-session"); + expect(headers?.["x-opencode-client"]).toBe("configured-client"); + }); }); diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts new file mode 100644 index 00000000..1d565c7e --- /dev/null +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -0,0 +1,153 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Api, + type AssistantMessage, + createAssistantMessageEventStream, + type Model, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("createAgentSession stream options", () => { + let tempDir: string; + let cwd: string; + let agentDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-sdk-stream-options-")); + cwd = join(tempDir, "project"); + agentDir = join(tempDir, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function createModel(api: Api): Model { + return { + id: "capture-model", + name: "Capture Model", + api, + provider: "capture-provider", + baseUrl: "https://capture.invalid/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; + } + + function createDoneStream(api: Api) { + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api, + provider: "capture-provider", + model: "capture-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.end(message); + return stream; + } + + async function captureStreamOptions( + api: Api, + settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number }, + requestOptions: SimpleStreamOptions = {}, + ): Promise { + const model = createModel(api); + const settingsManager = SettingsManager.inMemory(settings); + + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + authStorage.setRuntimeApiKey(model.provider, "test-api-key"); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + let capturedOptions: SimpleStreamOptions | undefined; + + modelRegistry.registerProvider(model.provider, { + api, + streamSimple: (_model, _context, providerOptions) => { + capturedOptions = providerOptions; + return createDoneStream(api); + }, + }); + + const sessionManager = SessionManager.inMemory(cwd); + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + }); + + try { + await session.agent.streamFn(model, { messages: [] }, requestOptions); + return capturedOptions; + } finally { + session.dispose(); + modelRegistry.unregisterProvider(model.provider); + } + } + + it("forwards httpIdleTimeoutMs as timeoutMs for OpenAI Codex", async () => { + const options = await captureStreamOptions("openai-codex-responses", { httpIdleTimeoutMs: 1234 }); + + expect(options?.timeoutMs).toBe(1234); + }); + + it("defaults timeoutMs from httpIdleTimeoutMs for all providers", async () => { + const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 }); + + expect(options?.timeoutMs).toBe(1234); + }); + + it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => { + const options = await captureStreamOptions( + "openai-codex-responses", + { httpIdleTimeoutMs: 1234 }, + { timeoutMs: 0 }, + ); + + expect(options?.timeoutMs).toBe(0); + }); + + it("forwards websocketConnectTimeoutMs from settings", async () => { + const options = await captureStreamOptions("openai-codex-responses", { websocketConnectTimeoutMs: 1234 }); + + expect(options?.websocketConnectTimeoutMs).toBe(1234); + }); + + it("lets request websocketConnectTimeoutMs override settings", async () => { + const options = await captureStreamOptions( + "openai-codex-responses", + { websocketConnectTimeoutMs: 1234 }, + { websocketConnectTimeoutMs: 0 }, + ); + + expect(options?.websocketConnectTimeoutMs).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/session-id-readonly.test.ts b/packages/coding-agent/test/session-id-readonly.test.ts new file mode 100644 index 00000000..b6e97ce8 --- /dev/null +++ b/packages/coding-agent/test/session-id-readonly.test.ts @@ -0,0 +1,131 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-session-id-readonly-")); + tempDirs.push(dir); + return dir; +} + +function hasSessionWithId(root: string, sessionId: string): boolean { + if (!existsSync(root)) return false; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory() && hasSessionWithId(path, sessionId)) return true; + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + + try { + const firstLine = readFileSync(path, "utf8").split("\n", 1)[0]; + const header = JSON.parse(firstLine) as { type?: string; id?: string }; + if (header.type === "session" && header.id === sessionId) return true; + } catch { + // Ignore malformed session files. + } + } + return false; +} + +interface CliDirs { + agentDir: string; + projectDir: string; + sessionDir: string; +} + +async function runCli( + args: string[] | ((dirs: CliDirs) => string[]), + setup?: (dirs: CliDirs) => void, +): Promise<{ code: number | null; agentDir: string; stderr: string }> { + const tempRoot = createTempDir(); + const dirs: CliDirs = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionDir: join(tempRoot, "sessions"), + }; + mkdirSync(dirs.agentDir, { recursive: true }); + mkdirSync(dirs.projectDir, { recursive: true }); + setup?.(dirs); + const resolvedArgs = typeof args === "function" ? args(dirs) : args; + + let stderr = ""; + const code = await new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { + cwd: dirs.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: dirs.agentDir, + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", resolvePromise); + }); + + return { code, agentDir: dirs.agentDir, stderr }; +} + +function writeSession(sessionDir: string, cwd: string, id: string): void { + writeFileSync( + join(sessionDir, `${id}.jsonl`), + `${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`, + ); +} + +describe("--session-id read-only commands", () => { + it("does not reserve a session for --help", async () => { + const result = await runCli(["--session-id", "read-only-help", "--help"]); + + expect(result.code).toBe(0); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); + }); + + it("does not reserve a session for --list-models", async () => { + const result = await runCli(["--session-id", "read-only-models", "--list-models"]); + + expect(result.code).toBe(0); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false); + }); + + it("rejects an existing fork target session id", async () => { + const result = await runCli( + (dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"], + (dirs) => { + mkdirSync(dirs.sessionDir, { recursive: true }); + writeSession(dirs.sessionDir, dirs.projectDir, "source-id"); + writeSession(dirs.sessionDir, dirs.projectDir, "existing-id"); + }, + ); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Session already exists with id 'existing-id'"); + }); +}); + +describe("--session-id validation", () => { + it("rejects ids invalid under SessionManager rules without stack traces", async () => { + for (const id of ["-bad", "bad id"]) { + const result = await runCli(["--session-id", id, "-p", "hi"]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Session id must be non-empty"); + expect(result.stderr).not.toContain("SessionManager.create"); + } + }); +}); diff --git a/packages/coding-agent/test/session-manager/custom-session-id.test.ts b/packages/coding-agent/test/session-manager/custom-session-id.test.ts index f9c6ad0d..ee2da917 100644 --- a/packages/coding-agent/test/session-manager/custom-session-id.test.ts +++ b/packages/coding-agent/test/session-manager/custom-session-id.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; import { SessionManager } from "../../src/core/session-manager.ts"; @@ -13,6 +13,23 @@ describe("SessionManager.newSession with custom id", () => { expect(session.getSessionId()).toBe("my-custom-id"); }); + it("allows alphanumeric session ids with interior punctuation", () => { + const session = SessionManager.inMemory(); + session.newSession({ id: "abc-123_def.456" }); + expect(session.getSessionId()).toBe("abc-123_def.456"); + }); + + it("rejects invalid custom session ids", () => { + const invalidIds = ["", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def"]; + + for (const id of invalidIds) { + const session = SessionManager.inMemory(); + expect(() => session.newSession({ id })).toThrow( + "Session id must be non-empty, contain only alphanumeric characters", + ); + } + }); + it("generates a UUIDv7 id when no id is provided", () => { const session = SessionManager.inMemory(); session.newSession(); @@ -46,6 +63,18 @@ describe("SessionManager.newSession with custom id", () => { expect(session.getHeader()!.id).toBe(session.getSessionId()); }); + it("uses the provided id when creating a persisted session", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-")); + const session = SessionManager.create(tempDir, tempDir, { id: "created-session-id" }); + + expect(session.getSessionId()).toBe("created-session-id"); + expect(session.getHeader()!.id).toBe("created-session-id"); + const sessionFile = session.getSessionFile()!; + expect(sessionFile).toContain("created-session-id"); + expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_created-session-id\.jsonl$/); + expect(existsSync(sessionFile)).toBe(false); + }); + it("generates a UUIDv7 id when creating a branched session", () => { const session = SessionManager.inMemory(); const firstId = session.appendMessage({ @@ -106,4 +135,28 @@ describe("SessionManager.newSession with custom id", () => { expect(header!.id).toMatch(UUID_V7_RE); expect(header!.parentSession).toBe(sourcePath); }); + + it("uses the provided id when forking from another session file", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-")); + const sourcePath = join(tempDir, "source.jsonl"); + writeFileSync( + sourcePath, + `${JSON.stringify({ + type: "session", + version: 3, + id: "source-session-id", + timestamp: new Date().toISOString(), + cwd: tempDir, + })}\n`, + ); + + const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir, { id: "forked-session-id" }); + const header = forked.getHeader(); + expect(header).not.toBeNull(); + expect(header!.id).toBe("forked-session-id"); + expect(header!.parentSession).toBe(sourcePath); + const sessionFile = forked.getSessionFile()!; + expect(sessionFile).toContain("forked-session-id"); + expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_forked-session-id\.jsonl$/); + }); }); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index c744c3c3..626dd52c 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { constants as bufferConstants } from "buffer"; +import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -63,6 +64,35 @@ describe("loadEntriesFromFile", () => { const entries = loadEntriesFromFile(file); expect(entries).toHaveLength(2); }); + + it("opens session files larger than Node's max string length", () => { + const file = join(tempDir, "large.jsonl"); + writeFileSync( + file, + '{"type":"session","version":3,"id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n', + ); + + const fd = openSync(file, "r+"); + try { + const newline = Buffer.from("\n"); + const stride = 16 * 1024 * 1024; + for (let offset = stride; offset <= bufferConstants.MAX_STRING_LENGTH + stride; offset += stride) { + writeSync(fd, newline, 0, newline.length, offset); + } + } finally { + closeSync(fd); + } + + appendFileSync( + file, + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', + ); + + const sessionManager = SessionManager.open(file, tempDir); + expect(sessionManager.getSessionId()).toBe("abc"); + expect(sessionManager.getEntries()).toHaveLength(1); + expect(sessionManager.buildSessionContext().messages).toEqual([{ role: "user", content: "hi", timestamp: 1 }]); + }); }); describe("findMostRecentSession", () => { @@ -124,6 +154,86 @@ describe("findMostRecentSession", () => { expect(findMostRecentSession(tempDir)).toBe(valid); }); + + it("filters most recent session by cwd", async () => { + const projectA = join(tempDir, "project-a"); + const projectB = join(tempDir, "project-b"); + const fileA = join(tempDir, "a.jsonl"); + const fileB = join(tempDir, "b.jsonl"); + + writeFileSync( + fileA, + `${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`, + ); + await new Promise((r) => setTimeout(r, 10)); + writeFileSync( + fileB, + `${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`, + ); + + expect(findMostRecentSession(tempDir, projectA)).toBe(fileA); + expect(findMostRecentSession(tempDir, projectB)).toBe(fileB); + }); +}); + +describe("SessionManager custom flat session directory", () => { + let tempDir: string; + let projectA: string; + let projectB: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-test-${Date.now()}`); + projectA = join(tempDir, "project-a"); + projectB = join(tempDir, "project-b"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPersistedSession(cwd: string, label: string): string { + const session = SessionManager.create(cwd, tempDir); + session.appendMessage({ role: "user", content: label, timestamp: Date.now() }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: `reply to ${label}` }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + const sessionFile = session.getSessionFile(); + if (!sessionFile) { + throw new Error("Expected persisted session file"); + } + return sessionFile; + } + + it("scopes current-folder APIs by cwd while listing all flat sessions", async () => { + const sessionA = createPersistedSession(projectA, "from A"); + await new Promise((r) => setTimeout(r, 10)); + const sessionB = createPersistedSession(projectB, "from B"); + + const currentA = await SessionManager.list(projectA, tempDir); + expect(currentA.map((session) => session.path)).toEqual([sessionA]); + + const all = await SessionManager.listAll(tempDir); + expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB])); + + const continuedA = SessionManager.continueRecent(projectA, tempDir); + expect(continuedA.getSessionFile()).toBe(sessionA); + }); }); describe("SessionManager.setSessionFile with corrupted files", () => { diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts index 7500746e..353454c7 100644 --- a/packages/coding-agent/test/session-manager/labels.test.ts +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -142,6 +142,19 @@ describe("SessionManager labels", () => { expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp); }); + it("rewires children of removed labels when forking", () => { + const session = SessionManager.inMemory(); + + const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendLabelChange(msg1Id, "checkpoint"); + const modelChangeId = session.appendModelChange("anthropic", "claude-test"); + const msg2Id = session.appendMessage({ role: "user", content: "followup", timestamp: 2 }); + + session.createBranchedSession(msg2Id); + + expect(session.getEntry(modelChangeId)?.parentId).toBe(msg1Id); + }); + it("labels not on path are not preserved in createBranchedSession", () => { const session = SessionManager.inMemory(); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index a503e3cc..d86c3f91 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -198,6 +198,24 @@ describe("SettingsManager", () => { }); }); + describe("theme setting", () => { + it("stores slash-separated automatic theme settings separately from fixed theme names", async () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ theme: "light/dark" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getTheme()).toBeUndefined(); + expect(manager.getThemeSetting()).toBe("light/dark"); + + manager.setTheme("solarized-light/tokyo-night"); + await manager.flush(); + + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.theme).toBe("solarized-light/tokyo-night"); + }); + }); + describe("error tracking", () => { it("should collect and clear load errors via drainErrors", () => { const globalSettingsPath = join(agentDir, "settings.json"); @@ -214,6 +232,61 @@ describe("SettingsManager", () => { }); }); + describe("project trust", () => { + it("should skip project settings when project is not trusted", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); + + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(manager.isProjectTrusted()).toBe(false); + expect(manager.getTheme()).toBe("global"); + expect(manager.getProjectSettings()).toEqual({}); + }); + + it("should reload project settings after trust changes to true", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + manager.setProjectTrusted(true); + + expect(manager.isProjectTrusted()).toBe(true); + expect(manager.getTheme()).toBe("project"); + }); + + it("should fail project settings writes when project is not trusted", async () => { + const projectSettingsPath = join(projectDir, ".pi", "settings.json"); + writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(() => manager.setProjectPackages(["npm:new"])).toThrow( + "Project is not trusted; refusing to write project settings", + ); + await manager.flush(); + + expect(manager.getProjectSettings()).toEqual({}); + expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); + }); + + it("should read default project trust from global settings only", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("always"); + }); + + it("should default invalid project trust settings to ask", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("ask"); + }); + }); + describe("project settings directory creation", () => { it("should not create .pi folder when only reading project settings", () => { // Create agent dir with global settings, but NO .pi folder in project diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts new file mode 100644 index 00000000..2d6f94ce --- /dev/null +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -0,0 +1,135 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-startup-session-name-")); + tempDirs.push(dir); + return dir; +} + +interface CliDirs { + agentDir: string; + projectDir: string; + sessionFile: string; +} + +interface CliResult { + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; +} + +function createSessionFile(projectDir: string, sessionFile: string): void { + const timestamp = new Date().toISOString(); + writeFileSync( + sessionFile, + `${JSON.stringify({ type: "session", version: 3, id: "existing-session", timestamp, cwd: projectDir })}\n${JSON.stringify( + { + type: "message", + id: "assistant-1", + parentId: null, + timestamp, + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + provider: "anthropic", + model: "claude-sonnet-4-5", + timestamp: Date.now(), + }, + }, + )}\n`, + ); +} + +function readSessionInfoNames(sessionFile: string): string[] { + return readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type?: string; name?: string }) + .filter((entry) => entry.type === "session_info") + .map((entry) => entry.name ?? ""); +} + +async function runCli(args: string[], dirs: CliDirs): Promise { + let stderr = ""; + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: dirs.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: dirs.agentDir, + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + return new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, 10_000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal, stderr }); + }); + }); +} + +function setup(): CliDirs { + const tempRoot = createTempDir(); + const dirs = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionFile: join(tempRoot, "session.jsonl"), + }; + mkdirSync(dirs.agentDir, { recursive: true }); + mkdirSync(dirs.projectDir, { recursive: true }); + createSessionFile(dirs.projectDir, dirs.sessionFile); + return dirs; +} + +describe("startup session name", () => { + it("sets --name on the selected session before runtime model validation", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " CLI Named Session ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]); + }); + + it("rejects empty --name values without appending session metadata", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("--name requires a non-empty value"); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index f6c426a1..f1b31eb3 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -80,8 +80,24 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { - it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => { - const result = await runCli(["--mode", "json", "--help"]); + it("prints --version to stdout when stdout is redirected", async () => { + const result = await runCli(["--version"]); + + expect(result.code).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(result.stderr).toBe(""); + }); + + it("prints plain --help to stdout when stdout is redirected", async () => { + const result = await runCli(["--help"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stderr).not.toContain("Usage:"); + }); + + it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["--mode", "json", "--help", "--approve"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); @@ -90,13 +106,23 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).toContain("Usage:"); }); - it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => { + it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["-p", "--help", "--approve"]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("changed 1 package in 471ms"); + expect(result.stderr).toContain("found 0 vulnerabilities"); + expect(result.stderr).toContain("Usage:"); + }); + + it("ignores untrusted project package installs for help", async () => { const result = await runCli(["-p", "--help"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("changed 1 package in 471ms"); - expect(result.stderr).toContain("found 0 vulnerabilities"); + expect(result.stderr).not.toContain("changed 1 package in 471ms"); + expect(result.stderr).not.toContain("found 0 vulnerabilities"); expect(result.stderr).toContain("Usage:"); }); }); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index e04732d0..ba5ae5c7 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -5,6 +5,7 @@ import { type Model, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { estimateTokens } from "../../src/core/compaction/index.ts"; import { createHarness, type Harness } from "./harness.ts"; type SessionWithCompactionInternals = { @@ -67,19 +68,20 @@ function useSummaryStreamFn(harness: Harness, summary: string): () => number { } function seedCompactableSession(harness: Harness): void { + harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); const now = Date.now(); harness.sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "message to compact" }], timestamp: now - 1000, }); - harness.sessionManager.appendMessage( - createAssistant(harness, { - stopReason: "stop", - totalTokens: 100, - timestamp: now - 500, - }), - ); + const assistant = createAssistant(harness, { + stopReason: "stop", + totalTokens: 100, + timestamp: now - 500, + }); + assistant.content = [{ type: "text", text: "assistant response to compact" }]; + harness.sessionManager.appendMessage(assistant); harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; } @@ -96,6 +98,7 @@ describe("AgentSession compaction characterization", () => { it("manually compacts using an extension-provided summary", async () => { const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, extensionFactories: [ (pi) => { pi.on("session_before_compact", async (event) => ({ @@ -116,8 +119,10 @@ describe("AgentSession compaction characterization", () => { const result = await harness.session.compact(); const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + const estimatedTokensAfter = harness.session.messages.reduce((sum, message) => sum + estimateTokens(message), 0); expect(result.summary).toBe("summary from extension"); + expect(result.estimatedTokensAfter).toBe(estimatedTokensAfter); expect(compactionEntries).toHaveLength(1); expect(harness.session.messages[0]?.role).toBe("compactionSummary"); }); @@ -145,7 +150,7 @@ describe("AgentSession compaction characterization", () => { const result = await harness.session.compact(); - expect(result.summary).toBe("summary from custom stream"); + expect(result.summary).toContain("summary from custom stream"); expect(getStreamCallCount()).toBe(1); }); @@ -159,12 +164,15 @@ describe("AgentSession compaction characterization", () => { await sessionInternals._runAutoCompaction("threshold", false); const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + const compactionEnd = harness.eventsOfType("compaction_end").at(-1); expect(compactionEntries).toHaveLength(1); + expect(compactionEnd?.result?.estimatedTokensAfter).toBeGreaterThan(0); expect(getStreamCallCount()).toBe(1); }); it("cancels in-progress manual compaction when abortCompaction is called", async () => { const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, extensionFactories: [ (pi) => { pi.on("session_before_compact", async (event) => { @@ -248,6 +256,37 @@ describe("AgentSession compaction characterization", () => { ); }); + it("compacts successful overflow responses without retrying", async () => { + const harness = await createHarness({ + settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } }, + models: [{ id: "faux-1", contextWindow: 1, maxTokens: 100 }], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "successful overflow compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("completed answer")]); + + await expect(harness.session.prompt("hello")).resolves.toBeUndefined(); + + const compactionEnd = harness.eventsOfType("compaction_end").at(-1); + expect(compactionEnd).toMatchObject({ + reason: "overflow", + aborted: false, + willRetry: false, + }); + expect(harness.faux.state.callCount).toBe(1); + }); + it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index f6469884..9c15ecf6 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -2,7 +2,7 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { ExtensionAPI } from "../../src/index.ts"; +import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts"; import { createHarness, getAssistantTexts, type Harness } from "./harness.ts"; describe("AgentSession model and extension characterization", () => { @@ -260,6 +260,34 @@ describe("AgentSession model and extension characterization", () => { expect(extensionApi).toBeDefined(); }); + it("allows extension commands to inspect live system prompt options", async () => { + const seenOptions: BuildSystemPromptOptions[] = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.registerCommand("inspect-options", { + description: "Inspect system prompt options", + handler: async (_args, ctx) => { + const options = ctx.getSystemPromptOptions(); + seenOptions.push(options); + options.selectedTools?.push("mutated_tool"); + }, + }); + }, + ], + }); + harnesses.push(harness); + + await harness.session.prompt("/inspect-options"); + await harness.session.prompt("/inspect-options"); + + expect(seenOptions).toHaveLength(2); + expect(seenOptions[0]).toBe(seenOptions[1]); + expect(seenOptions[0]?.cwd).toBe(harness.tempDir); + expect(seenOptions[0]?.selectedTools).toContain("read"); + expect(seenOptions[1]?.selectedTools).toContain("mutated_tool"); + }); + it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => { const harness = await createHarness({ extensionFactories: [ diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index dde25f61..94e10a3e 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,6 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; +import type { InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; @@ -259,6 +260,80 @@ describe("AgentSession prompt characterization", () => { expect(getMessageText(harness.session.messages[0]!)).toBe("from extension"); }); + it("does not report streamingBehavior to input handlers while idle", async () => { + const inputEvents: InputEvent[] = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("ok")]); + + await harness.session.prompt("idle", { streamingBehavior: "followUp" }); + + expect(inputEvents).toHaveLength(1); + expect(inputEvents[0]?.streamingBehavior).toBeUndefined(); + }); + + it("reports streamingBehavior to input handlers while streaming", async () => { + let releaseToolExecution: (() => void) | undefined; + const toolRelease = new Promise((resolve) => { + releaseToolExecution = resolve; + }); + const inputEvents: InputEvent[] = []; + const waitTool: AgentTool = { + name: "wait", + label: "Wait", + description: "Wait for release", + parameters: Type.Object({}), + execute: async () => { + await toolRelease; + return { + content: [{ type: "text", text: "released" }], + details: {}, + }; + }, + }; + const harness = await createHarness({ + tools: [waitTool], + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + + const sawToolStart = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "tool_execution_start") { + unsubscribe(); + resolve(); + } + }); + }); + + const promptPromise = harness.session.prompt("start"); + await sawToolStart; + await harness.session.prompt("queued", { streamingBehavior: "followUp" }); + + expect(inputEvents.map((event) => event.streamingBehavior)).toEqual([undefined, "followUp"]); + + releaseToolExecution?.(); + await promptPromise; + }); + it("throws when prompted during streaming without a streamingBehavior", async () => { let releaseToolExecution: (() => void) | undefined; const toolRelease = new Promise((resolve) => { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 3db7c14b..a1c3fd8a 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -419,4 +419,27 @@ describe("AgentSession queue characterization", () => { 'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.', ); }); + + it("delivers follow-ups queued during agent_end", async () => { + let sent = false; + const harness = await createHarness({ + extensionFactories: [ + (pi: ExtensionAPI) => { + pi.on("agent_end", async () => { + if (sent) return; + sent = true; + pi.sendUserMessage("conflict report", { deliverAs: "followUp" }); + }); + }, + ], + }); + harnesses.push(harness); + + harness.setResponses([fauxAssistantMessage("reply"), fauxAssistantMessage("follow-up reply")]); + + await harness.session.prompt("hello"); + await harness.session.agent.waitForIdle(); + + expect(getUserTexts(harness)).toEqual(["hello", "conflict report"]); + }); }); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 090d5683..cec82013 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -60,6 +60,9 @@ export interface HarnessOptions { settings?: Partial; systemPrompt?: string; tools?: AgentTool[]; + initialActiveToolNames?: string[]; + allowedToolNames?: string[]; + excludedToolNames?: string[]; resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; @@ -173,6 +176,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise void; + runtimeHost: { dispose: () => Promise }; + ui: { terminal: { drainInput: (ms: number) => Promise } }; + themeController: { disableAutoSync: () => void }; + stop: () => void; + sessionManager: SessionManager; +}; + +type InteractiveModePrototypeWithShutdown = { + shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown; +const tempDirs: string[] = []; +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +class ProcessExitError extends Error {} + +function createSessionManager(options: { sessionFile?: string } = {}): SessionManager { + return { + isPersisted: () => options.sessionFile !== undefined, + getSessionFile: () => options.sessionFile, + getSessionId: () => "test-session", + getSessionDir: () => "/tmp/pi-sessions", + usesDefaultSessionDir: () => true, + } as unknown as SessionManager; +} + +function createTempFile(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-shutdown-resume-hint-")); + tempDirs.push(dir); + const file = join(dir, "session.jsonl"); + writeFileSync(file, "\n"); + return file; +} + +function setStdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value }); +} + +function restoreStdoutIsTTY(): void { + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} + +function createContext(order: string[], sessionManager = createSessionManager()): ShutdownThis { + return { + isShuttingDown: false, + unregisterSignalHandlers: vi.fn(), + runtimeHost: { + dispose: vi.fn(async () => { + order.push("dispose"); + }), + }, + ui: { + terminal: { + drainInput: vi.fn(async () => { + order.push("drainInput"); + }), + }, + }, + themeController: { disableAutoSync: vi.fn() }, + stop: vi.fn(() => { + order.push("stop"); + }), + sessionManager, + }; +} + +async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise { + try { + await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + } +} + +describe("InteractiveMode.shutdown ordering (#5080)", () => { + afterEach(() => { + vi.restoreAllMocks(); + restoreStdoutIsTTY(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + + await callShutdown(context, { fromSignal: true }); + + expect(order).toEqual(["dispose", "drainInput", "stop"]); + expect(context.isShuttingDown).toBe(true); + }); + + test("interactive quit stops the TUI before emitting session_shutdown", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + + await callShutdown(context); + + expect(order).toEqual(["drainInput", "stop", "dispose"]); + }); + + test("interactive quit prints a resume hint for persisted sessions", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + setStdoutIsTTY(true); + const order: string[] = []; + const context = createContext(order, createSessionManager({ sessionFile: createTempFile() })); + + await callShutdown(context); + + expect(order).toEqual(["drainInput", "stop", "dispose"]); + expect(stdoutWrite).toHaveBeenCalledWith( + `${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`, + ); + }); + + test("signal-triggered shutdown does not print a resume hint", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + setStdoutIsTTY(true); + const order: string[] = []; + const context = createContext(order, createSessionManager({ sessionFile: createTempFile() })); + + await callShutdown(context, { fromSignal: true }); + + for (const call of stdoutWrite.mock.calls) { + expect(call[0]).not.toContain("To resume this session:"); + } + }); + + test("re-entrant shutdown is a no-op", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + context.isShuttingDown = true; + + await callShutdown(context, { fromSignal: true }); + + expect(order).toEqual([]); + expect(context.runtimeHost.dispose).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5109-exclude-tools.test.ts b/packages/coding-agent/test/suite/regressions/5109-exclude-tools.test.ts new file mode 100644 index 00000000..0e745416 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5109-exclude-tools.test.ts @@ -0,0 +1,81 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import type { ExtensionFactory } from "../../../src/index.ts"; +import { createHarness } from "../harness.ts"; + +function toolNames(tools: Array<{ name: string }>): string[] { + return tools.map((tool) => tool.name).sort(); +} + +describe("regression #5109: exclude tools", () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.on("session_start", () => { + pi.registerTool({ + name: "ask_question", + label: "Ask Question", + description: "Ask a question", + promptSnippet: "Ask a question", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamic test tool", + promptSnippet: "Run dynamic test behavior", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + }); + }, + ]; + + it("filters built-in and extension tools from available and active tools", async () => { + const harness = await createHarness({ + excludedToolNames: ["read", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + const allToolNames = toolNames(harness.session.getAllTools()); + expect(allToolNames).not.toContain("read"); + expect(allToolNames).not.toContain("ask_question"); + expect(allToolNames).toContain("bash"); + expect(allToolNames).toContain("dynamic_tool"); + expect(harness.session.getActiveToolNames().sort()).toEqual(["bash", "dynamic_tool", "edit", "write"]); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + expect(harness.session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); + } finally { + harness.cleanup(); + } + }); + + it("lets excluded tools override the allowlist", async () => { + const harness = await createHarness({ + allowedToolNames: ["read", "bash", "ask_question"], + excludedToolNames: ["read", "ask_question"], + initialActiveToolNames: ["read", "bash", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + expect(toolNames(harness.session.getAllTools())).toEqual(["bash"]); + expect(harness.session.getActiveToolNames()).toEqual(["bash"]); + expect(harness.session.systemPrompt).toContain("- bash:"); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + } finally { + harness.cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts b/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts new file mode 100644 index 00000000..cb78a7f6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { type BashOperations, createBashTool } from "../../../src/core/tools/bash.ts"; + +function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { + return ( + result.content + ?.filter((block) => block.type === "text") + .map((block) => block.text ?? "") + .join("\n") ?? "" + ); +} + +describe("regression #5208: late bash output callbacks", () => { + it("ignores output callbacks after bash operations resolve", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + onData(Buffer.from("before\n", "utf-8")); + setTimeout(() => onData(Buffer.from("late\n", "utf-8")), 0); + return { exitCode: 0 }; + }, + }; + const bash = createBashTool(process.cwd(), { operations }); + + const result = await bash.execute("test-call-late-output", { command: "late-output" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(getTextOutput(result).trim()).toBe("before"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts b/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts new file mode 100644 index 00000000..aeaa49fc --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts @@ -0,0 +1,79 @@ +import type { ChildProcessByStdio } from "node:child_process"; +import type { Readable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { spawnProcess, waitForChildProcess } from "../../../src/utils/child-process.ts"; + +/** + * Regression test for https://github.com/earendil-works/pi/issues/5303 + * + * waitForChildProcess armed a fixed 100ms timer on `exit` and destroyed the + * stdio streams when it fired. When a short-lived detached descendant kept the + * stdout pipe open, `close` never fired, so that timer was the only thing that + * resolved the wait, and any output written more than 100ms after exit was + * binned. In practice every git commit whose pre-commit hook runs lint-staged + * came back truncated mid-listr2 output, read by the model as a hang. + * + * The fix re-arms the grace on each chunk, so an actively writing pipe keeps us + * reading while a genuinely idle held-open handle still releases after the + * grace elapses. Both behaviours are covered below. + */ +describe.skipIf(process.platform === "win32")("issue #5303 bash output truncation past exit", () => { + let child: ChildProcessByStdio | undefined; + + afterEach(() => { + if (child?.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Already gone. + } + } + child = undefined; + }); + + it("captures output emitted after exit while a detached child holds stdout open", async () => { + // The shell exits immediately, but a backgrounded subshell keeps the stdout + // pipe open and emits ticks every 50ms, the last well past the 100ms grace. + const command = 'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &'; + child = spawnProcess("/bin/sh", ["-c", command], { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }) as ChildProcessByStdio; + + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + const exitCode = await waitForChildProcess(child); + + expect(exitCode).toBe(0); + expect(output).toContain("HEAD"); + expect(output).toContain("TICK6"); + }); + + it("resolves promptly when a detached child holds stdout open but stays quiet", async () => { + // The shell exits, but a backgrounded sleeper inherits the stdout pipe and + // keeps it open for a long time without writing. `close` never fires, so we + // must still release via the idle grace rather than hang on the open handle. + const command = 'printf "DONE\\n"; ( sleep 30 ) &'; + child = spawnProcess("/bin/sh", ["-c", command], { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }) as ChildProcessByStdio; + + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + const start = Date.now(); + const exitCode = await waitForChildProcess(child); + const elapsed = Date.now() - start; + + expect(exitCode).toBe(0); + expect(output).toContain("DONE"); + // Must not wait for the 30s sleeper; the idle grace releases us in well under a second. + expect(elapsed).toBeLessThan(2000); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts new file mode 100644 index 00000000..06562b3d --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -0,0 +1,93 @@ +import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { KeybindingsManager } from "../../../src/core/keybindings.ts"; +import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; + +vi.mock("../../../src/utils/open-browser.ts", () => ({ + openBrowser: vi.fn(), +})); + +function createDialog(): LoginDialogComponent { + return new LoginDialogComponent( + { requestRender: vi.fn() } as unknown as TUI, + "prompt-repro", + () => {}, + "Prompt Repro", + ); +} + +function renderDialog(dialog: LoginDialogComponent): string[] { + return stripAnsi(dialog.render(120).join("\n")) + .split("\n") + .map((line) => line.trimEnd()); +} + +function countRenderedValue(lines: string[], value: string): number { + return lines.filter((line) => line.trim() === `> ${value}`).length; +} + +describe("LoginDialogComponent OAuth prompts", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + test("keeps previous prompt input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const firstPrompt = dialog.showPrompt("First prompt:", "first-value"); + dialog.handleInput("first-value"); + dialog.handleInput("\n"); + await expect(firstPrompt).resolves.toBe("first-value"); + + const secondPrompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("First prompt:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "first-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(secondPrompt).resolves.toBe("second-secret-demo"); + }); + + test("preserves auth instructions when showing a prompt", () => { + const dialog = createDialog(); + + dialog.showAuth("https://example.invalid/login", "Authorize the extension"); + dialog.showPrompt("First prompt:"); + + const output = renderDialog(dialog).join("\n"); + expect(output).toContain("https://example.invalid/login"); + expect(output).toContain("Authorize the extension"); + expect(output).toContain("First prompt:"); + }); + + test("keeps previous manual input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const manualInput = dialog.showManualInput("Paste callback URL:"); + dialog.handleInput("callback-value"); + dialog.handleInput("\n"); + await expect(manualInput).resolves.toBe("callback-value"); + + const prompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("Paste callback URL:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "callback-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(prompt).resolves.toBe("second-secret-demo"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts new file mode 100644 index 00000000..58bc17f8 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentSession } from "../../../src/core/agent-session.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { convertToLlm } from "../../../src/core/messages.ts"; +import { ModelRegistry } from "../../../src/core/model-registry.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { createTestResourceLoader } from "../../utilities.ts"; + +describe("regression #5596: missing configured theme export", () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } + initTheme("dark"); + }); + + it("exports with the active fallback theme when the configured theme is missing", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-5596-")); + const faux = registerFauxProvider({ + models: [{ id: "faux-1", reasoning: false }], + }); + faux.setResponses([fauxAssistantMessage("hello")]); + + const model = faux.getModel(); + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey(model.provider, "faux-key"); + const modelRegistry = ModelRegistry.inMemory(authStorage); + modelRegistry.registerProvider(model.provider, { + baseUrl: model.baseUrl, + apiKey: "faux-key", + api: faux.api, + models: faux.models.map((registeredModel) => ({ + id: registeredModel.id, + name: registeredModel.name, + api: registeredModel.api, + reasoning: registeredModel.reasoning, + input: registeredModel.input, + cost: registeredModel.cost, + contextWindow: registeredModel.contextWindow, + maxTokens: registeredModel.maxTokens, + baseUrl: registeredModel.baseUrl, + })), + }); + + const settingsManager = SettingsManager.inMemory({ theme: "missing-theme" }); + const sessionManager = SessionManager.create(tempDir, join(tempDir, "sessions")); + const agent = new Agent({ + getApiKey: () => "faux-key", + initialState: { + model, + systemPrompt: "You are a test assistant.", + tools: [], + }, + convertToLlm, + }); + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + cwd: tempDir, + modelRegistry, + resourceLoader: createTestResourceLoader(), + }); + cleanups.push(() => { + session.dispose(); + faux.unregister(); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + await session.prompt("hi"); + initTheme(settingsManager.getTheme()); + + const outputPath = join(tempDir, "export.html"); + await expect(session.exportToHtml(outputPath)).resolves.toBe(outputPath); + expect(existsSync(outputPath)).toBe(true); + expect(settingsManager.getTheme()).toBe("missing-theme"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts b/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts new file mode 100644 index 00000000..e625a3c6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts @@ -0,0 +1,91 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../../../src/config.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { ModelRegistry } from "../../../src/core/model-registry.ts"; +import { runMigrations } from "../../../src/migrations.ts"; +import { createHarness } from "../harness.ts"; + +describe("regression #5661: uppercase models.json header values", () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } + }); + + function withAgentDir(agentDir: string, fn: () => void): void { + const previousAgentDir = process.env[ENV_AGENT_DIR]; + process.env[ENV_AGENT_DIR] = agentDir; + try { + fn(); + } finally { + if (previousAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = previousAgentDir; + } + } + } + + it("keeps uppercase header strings as literals during startup migrations", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + cleanups.push(harness.cleanup); + + const envKeys = ["CUSTOM_API_KEY", "BEARER"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } + cleanups.push(() => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + const modelsPath = join(harness.tempDir, "models.json"); + writeFileSync( + modelsPath, + `${JSON.stringify( + { + providers: { + "my-provider": { + baseUrl: "https://example.com/v1", + apiKey: "CUSTOM_API_KEY", + api: "openai-completions", + headers: { Authorization: "BEARER" }, + models: [{ id: "my-model" }], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + withAgentDir(harness.tempDir, () => runMigrations(harness.tempDir)); + + const migrated = JSON.parse(readFileSync(modelsPath, "utf-8")) as { + providers: Record }>; + }; + expect(migrated.providers["my-provider"]?.apiKey).toBe("CUSTOM_API_KEY"); + expect(migrated.providers["my-provider"]?.headers?.Authorization).toBe("BEARER"); + + const registry = ModelRegistry.create(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath); + const model = registry.find("my-provider", "my-model"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_API_KEY", + headers: { Authorization: "BEARER" }, + }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts new file mode 100644 index 00000000..75e82fb7 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; + +// Regression for https://github.com/earendil-works/pi/issues/5724 +// +// `proper-lockfile` installs `signal-exit`, whose signal listener re-sends +// SIGTERM/SIGHUP when it observes no other process listeners during the same +// signal dispatch. InteractiveMode must therefore keep its signal handlers +// registered until async terminal cleanup has completed. + +type ShutdownThis = { + isShuttingDown: boolean; + unregisterSignalHandlers: () => void; + runtimeHost: { dispose: () => Promise }; + ui: { terminal: { drainInput: (ms: number) => Promise } }; + themeController: { disableAutoSync: () => void }; + stop: () => void; +}; + +type InteractiveModePrototypeWithShutdown = { + shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown; + +class ProcessExitError extends Error {} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve: (() => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: () => resolve?.(), + }; +} + +async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise { + try { + await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + } +} + +describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("keeps signal handlers registered while signal-triggered cleanup is pending", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + + const order: string[] = []; + const dispose = deferred(); + const context: ShutdownThis = { + isShuttingDown: false, + unregisterSignalHandlers: vi.fn(() => { + order.push("unregister"); + }), + runtimeHost: { + dispose: vi.fn(() => { + order.push("dispose"); + return dispose.promise; + }), + }, + ui: { + terminal: { + drainInput: vi.fn(async () => { + order.push("drainInput"); + }), + }, + }, + themeController: { disableAutoSync: vi.fn() }, + stop: vi.fn(() => { + order.push("stop"); + }), + }; + + const shutdownPromise = callShutdown(context, { fromSignal: true }); + await Promise.resolve(); + + expect(order).toEqual(["dispose"]); + expect(context.unregisterSignalHandlers).not.toHaveBeenCalled(); + + dispose.resolve(); + await shutdownPromise; + + expect(order).toEqual(["dispose", "drainInput", "stop"]); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5868-rpc-unknown-command-id.test.ts b/packages/coding-agent/test/suite/regressions/5868-rpc-unknown-command-id.test.ts new file mode 100644 index 00000000..7ee70261 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5868-rpc-unknown-command-id.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts"; +import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +// Regression for https://github.com/earendil-works/pi/issues/5868 + +const rpcIo = vi.hoisted(() => ({ + outputLines: [] as string[], + lineHandler: undefined as ((line: string) => void) | undefined, +})); + +vi.mock("../../../src/core/output-guard.js", () => ({ + flushRawStdout: vi.fn(async () => {}), + takeOverStdout: vi.fn(), + waitForRawStdoutBackpressure: vi.fn(async () => {}), + writeRawStdout: (line: string) => { + rpcIo.outputLines.push(line); + }, +})); + +vi.mock("../../../src/modes/interactive/theme/theme.js", () => ({ theme: {} })); + +vi.mock("../../../src/modes/rpc/jsonl.js", () => ({ + attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => { + rpcIo.lineHandler = onLine; + return () => { + rpcIo.lineHandler = undefined; + }; + }), + serializeJsonLine: (value: unknown) => `${JSON.stringify(value)}\n`, +})); + +type NodeListener = Parameters[1]; + +type ListenerSnapshot = { + stdinEnd: NodeListener[]; + signals: Map; +}; + +function takeListenerSnapshot(): ListenerSnapshot { + const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"]; + return { + stdinEnd: process.stdin.listeners("end") as NodeListener[], + signals: new Map(signals.map((signal) => [signal, process.listeners(signal) as NodeListener[]])), + }; +} + +function restoreListeners(snapshot: ListenerSnapshot): void { + for (const listener of process.stdin.listeners("end") as NodeListener[]) { + if (!snapshot.stdinEnd.includes(listener)) { + process.stdin.off("end", listener); + } + } + + for (const [signal, previousListeners] of snapshot.signals) { + for (const listener of process.listeners(signal) as NodeListener[]) { + if (!previousListeners.includes(listener)) { + process.off(signal, listener); + } + } + } +} + +function parseOutputLines(): Array> { + return rpcIo.outputLines + .flatMap((line) => line.split("\n")) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record); +} + +function createRuntimeHost(harness: Harness): AgentSessionRuntime { + return { + session: harness.session, + newSession: vi.fn(async () => ({ cancelled: true })), + switchSession: vi.fn(async () => ({ cancelled: true })), + fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })), + dispose: vi.fn(async () => {}), + setRebindSession: vi.fn(), + } as unknown as AgentSessionRuntime; +} + +describe("RPC unknown command responses (#5868)", () => { + afterEach(() => { + rpcIo.outputLines = []; + rpcIo.lineHandler = undefined; + }); + + test("preserves the request id on unknown command errors", async () => { + const listenerSnapshot = takeListenerSnapshot(); + const harness = await createHarness(); + + try { + void runRpcMode(createRuntimeHost(harness)); + await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined()); + + rpcIo.lineHandler?.(JSON.stringify({ id: "test", type: "foobar" })); + + await vi.waitFor(() => { + expect(parseOutputLines()).toContainEqual({ + id: "test", + type: "response", + command: "foobar", + success: false, + error: "Unknown command: foobar", + }); + }); + } finally { + harness.cleanup(); + restoreListeners(listenerSnapshot); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts b/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts new file mode 100644 index 00000000..ced01224 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts @@ -0,0 +1,131 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearExtensionCache, loadExtensions, loadExtensionsCached } from "../../../src/core/extensions/loader.ts"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; + +interface TestState { + moduleLoads?: number; + factoryRuns?: number; +} + +function state(): TestState { + const global = globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }; + if (!global.__extensionFactoryCacheTest) { + global.__extensionFactoryCacheTest = {}; + } + return global.__extensionFactoryCacheTest; +} + +function resetState(): void { + delete (globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }).__extensionFactoryCacheTest; +} + +function writeCountingExtension(filePath: string): void { + writeFileSync( + filePath, + ` +const state = (globalThis.__extensionFactoryCacheTest ??= {}); +state.moduleLoads = (state.moduleLoads ?? 0) + 1; + +export default function () { + state.factoryRuns = (state.factoryRuns ?? 0) + 1; +} +`, + "utf-8", + ); +} + +describe("extension factory cache", () => { + const roots: string[] = []; + + function fixture(name: string) { + const root = join(tmpdir(), `pi-extension-cache-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + roots.push(root); + return { root, cwd, agentDir }; + } + + beforeEach(() => { + resetState(); + clearExtensionCache(); + }); + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root && existsSync(root)) { + rmSync(root, { recursive: true, force: true }); + } + } + resetState(); + clearExtensionCache(); + }); + + it("caches extension modules for cached same-cwd loads but reruns factories", async () => { + const { root, cwd } = fixture("same-cwd"); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + const first = await loadExtensionsCached([extensionPath], cwd); + const second = await loadExtensionsCached([extensionPath], cwd); + + expect(state().moduleLoads).toBe(1); + expect(state().factoryRuns).toBe(2); + expect(first.extensions[0]).not.toBe(second.extensions[0]); + expect(first.runtime).not.toBe(second.runtime); + }); + + it("does not cache direct loadExtensions calls", async () => { + const { root, cwd } = fixture("direct"); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + await loadExtensions([extensionPath], cwd); + await loadExtensions([extensionPath], cwd); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(2); + }); + + it("clears the cache on resource loader reload", async () => { + const { cwd, agentDir } = fixture("reload"); + const extensionDir = join(agentDir, "extensions"); + mkdirSync(extensionDir, { recursive: true }); + writeCountingExtension(join(extensionDir, "counting.ts")); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + }); + + await loader.reload(); + await loader.reload(); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(2); + }); + + it("keeps the cache scoped to one cwd", async () => { + const { root } = fixture("cross-cwd"); + const firstCwd = join(root, "first"); + const secondCwd = join(root, "second"); + mkdirSync(firstCwd, { recursive: true }); + mkdirSync(secondCwd, { recursive: true }); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + await loadExtensionsCached([extensionPath], firstCwd); + await loadExtensionsCached([extensionPath], secondCwd); + await loadExtensionsCached([extensionPath], secondCwd); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(3); + }); +}); diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 92d8aa39..6f311e49 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts"; import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; describe("syntax highlight renderer", () => { @@ -46,3 +48,29 @@ describe("syntax highlight renderer", () => { expect(rendered).toContain("[number:1]"); }); }); + +describe("theme syntax highlighting", () => { + beforeEach(() => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + initTheme("dark"); + }); + + afterEach(() => { + resetCapabilitiesCache(); + }); + + it("colors diff additions and deletions in fenced diff blocks", () => { + const lines = highlightCode("-old\n+new\n", "diff"); + + expect(lines[0]).toBe("\x1b[38;2;204;102;102m-old\x1b[39m"); + expect(lines[1]).toBe("\x1b[38;2;181;189;104m+new\x1b[39m"); + }); + + it("keeps cli-highlight default styled scopes mapped to theme styles", () => { + expect(highlightCode("const re = /foo+/gi;", "javascript")[0]).toContain( + "\x1b[38;2;206;145;120m/foo+/gi\x1b[39m", + ); + expect(highlightCode("@decorator", "python")[0]).toBe("\x1b[38;2;128;128;128m@decorator\x1b[39m"); + expect(highlightCode("
    ", "html")[0]).toContain("\x1b[38;2;86;156;214mdiv\x1b[39m"); + }); +}); diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 7bb280b2..6bdc597b 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -1,24 +1,26 @@ -import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { type RgbColor, resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; import { afterEach, describe, expect, it } from "vitest"; import { - detectTerminalBackground, + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, getThemeByName, getThemeForRgbColor, - parseOsc11BackgroundColor, + parseAutoThemeSetting, + resolveThemeSetting, } from "../src/modes/interactive/theme/theme.ts"; afterEach(() => { resetCapabilitiesCache(); }); -describe("detectTerminalBackground", () => { +describe("detectTerminalBackgroundFromEnv", () => { it("uses the COLORFGBG background color index", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({ theme: "light", source: "COLORFGBG", confidence: "high", }); - expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({ theme: "dark", source: "COLORFGBG", confidence: "high", @@ -26,11 +28,11 @@ describe("detectTerminalBackground", () => { }); it("uses the last COLORFGBG field as the background", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); }); it("defaults to dark without terminal background hints", () => { - expect(detectTerminalBackground({ env: {} })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({ theme: "dark", source: "fallback", confidence: "low", @@ -38,6 +40,65 @@ describe("detectTerminalBackground", () => { }); }); +describe("detectTerminalBackgroundTheme", () => { + it("uses the queried terminal background before environment hints", async () => { + let queriedTimeoutMs: number | undefined; + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + queriedTimeoutMs = timeoutMs; + return { r: 250, g: 250, b: 250 }; + }, + }, + }); + + expect(queriedTimeoutMs).toBe(250); + expect(detection).toMatchObject({ + theme: "light", + source: "terminal background", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query returns no color", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + return undefined; + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "dark", + source: "COLORFGBG", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query fails", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "0;15" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + throw new Error("terminal write failed"); + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "light", + source: "COLORFGBG", + confidence: "high", + }); + }); +}); + describe("theme color mode", () => { it("uses terminal capabilities", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); @@ -54,18 +115,19 @@ describe("theme color mode", () => { }); }); -describe("parseOsc11BackgroundColor", () => { - it("parses 16-bit OSC 11 rgb responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 }); - }); - - it("parses OSC 11 hex responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 }); - expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 }); - }); - +describe("theme detection from RGB", () => { it("classifies RGB colors by luminance", () => { expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark"); expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light"); }); }); + +describe("theme setting helpers", () => { + it("parses and resolves automatic theme settings", () => { + expect(parseAutoThemeSetting("light/dark")).toEqual({ lightTheme: "light", darkTheme: "dark" }); + expect(resolveThemeSetting("dark", "light")).toBe("dark"); + expect(resolveThemeSetting("light/dark", "light")).toBe("light"); + expect(resolveThemeSetting("light/dark", "dark")).toBe("dark"); + expect(resolveThemeSetting("light/dark/extra", "dark")).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 9a3d1639..bf890959 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -67,6 +67,37 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).toContain("custom result"); }); + test("self-rendered empty tool rows take no layout space", () => { + const toolDefinition: ToolDefinition = { + ...createBaseToolDefinition(), + renderShell: "self", + renderCall: () => new Text("", 0, 0), + renderResult: () => new Text("", 0, 0), + }; + + const component = new ToolExecutionComponent( + "custom_tool", + "tool-empty-self-render", + {}, + {}, + toolDefinition, + createFakeTui(), + process.cwd(), + ); + expect(component.render(120)).toEqual([]); + + component.updateResult( + { + content: [], + details: {}, + isError: false, + }, + false, + ); + + expect(component.render(120)).toEqual([]); + }); + test("uses built-in rendering for built-in overrides without custom renderers", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("edit"), @@ -191,6 +222,7 @@ describe("ToolExecutionComponent parity", () => { process.cwd(), ); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); + component.setExpanded(true); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("override call"); expect(rendered).toContain("hello"); @@ -362,12 +394,38 @@ describe("ToolExecutionComponent parity", () => { { content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false }, false, ); + component.setExpanded(true); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("one"); expect(rendered).toContain("two"); expect(rendered).not.toContain("two\n\n"); }); + test("collapses ordinary read results until expanded", () => { + const component = new ToolExecutionComponent( + "read", + "tool-ordinary-read-collapsed", + { path: "notes.txt" }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + component.updateResult( + { content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false }, + false, + ); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain("read"); + expect(collapsed).toContain("notes.txt"); + expect(collapsed).not.toContain("hidden content"); + + component.setExpanded(true); + const expanded = stripAnsi(component.render(120).join("\n")); + expect(expanded).toContain("hidden content"); + }); + for (const scenario of [ { title: "SKILL.md", diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index c5e8fa6f..f8b09c90 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -44,6 +44,7 @@ describe("Coding Agent Tools", () => { }); afterEach(() => { + vi.restoreAllMocks(); // Clean up test directory rmSync(testDir, { recursive: true, force: true }); }); @@ -535,6 +536,56 @@ describe("Coding Agent Tools", () => { expect(getShellConfigSpy).toHaveBeenCalledWith("/custom/bash"); }); + it("should send commands over stdin when shell resolution requires it", async () => { + vi.spyOn(shellModule, "getShellConfig").mockReturnValue({ + shell: process.execPath, + args: [ + "-e", + 'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { process.stdout.write(input); });', + ], + commandTransport: "stdin", + }); + const chunks: Buffer[] = []; + const ops = createLocalBashOperations({ shellPath: "C:\\Windows\\System32\\bash.exe" }); + const nameExpansion = "$" + "{name}"; + const countExpansion = "$" + "{count}"; + const iExpansion = "$" + "{i}"; + const command = `name='World'; echo "Hello, ${nameExpansion}!"; count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`; + + const result = await ops.exec(command, testDir, { + onData: (data) => chunks.push(data), + }); + + expect(result.exitCode).toBe(0); + expect(Buffer.concat(chunks).toString("utf-8")).toBe(command); + }); + + it("should resolve legacy WSL bash.exe to stdin command transport", () => { + if (process.platform === "win32") return; + const originalCwd = process.cwd(); + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const shellPath = "C:\\Windows\\System32\\bash.exe"; + writeFileSync(join(testDir, shellPath), ""); + try { + process.chdir(testDir); + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + expect(shellModule.getShellConfig(shellPath)).toEqual({ + shell: shellPath, + args: ["-s"], + commandTransport: "stdin", + }); + } finally { + process.chdir(originalCwd); + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + it("should prepend command prefix when configured", async () => { const bashWithPrefix = createBashTool(testDir, { commandPrefix: "export TEST_VAR=hello", @@ -980,6 +1031,57 @@ describe("edit tool fuzzy matching", () => { expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n"); }); + + it("should preserve the correct occurrence when fuzzy replacement equals a nearby line", async () => { + const testFile = join(testDir, "fuzzy-preserve-duplicate-line.txt"); + const originalContent = ["replace me\u0020\u0020\u0020", "after\u0020\u0020\u0020", ""].join("\n"); + writeFileSync(testFile, originalContent); + + const result = await editTool.execute("test-fuzzy-preserve-duplicate-line", { + path: testFile, + edits: [{ oldText: "replace me\n", newText: "after\n" }], + }); + + const expectedContent = ["after", "after\u0020\u0020\u0020", ""].join("\n"); + expect(readFileSync(testFile, "utf-8")).toBe(expectedContent); + expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent); + }); + + it("should preserve untouched lines and produce an applicable patch for fuzzy multi-edits", async () => { + const testFile = join(testDir, "fuzzy-preserve-multi.txt"); + const originalContent = [ + "keep before\u0020\u0020", + "first target\u0020\u0020", + "first after", + "keep middle\u0020\u0020\u0020", + "second target\u0020\u0020", + "second after", + "keep after\u0020\u0020", + "", + ].join("\n"); + writeFileSync(testFile, originalContent); + + const result = await editTool.execute("test-fuzzy-preserve-multi", { + path: testFile, + edits: [ + { oldText: "first target\nfirst after", newText: "FIRST\nFIRST2" }, + { oldText: "second target\nsecond after", newText: "SECOND\nSECOND2" }, + ], + }); + + const expectedContent = [ + "keep before\u0020\u0020", + "FIRST", + "FIRST2", + "keep middle\u0020\u0020\u0020", + "SECOND", + "SECOND2", + "keep after\u0020\u0020", + "", + ].join("\n"); + expect(readFileSync(testFile, "utf-8")).toBe(expectedContent); + expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent); + }); }); describe("edit tool CRLF handling", () => { diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 8f423dc5..4281b986 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -1,4 +1,5 @@ -import { setKeybindings } from "@earendil-works/pi-tui"; +import { stripVTControlCharacters } from "node:util"; +import { setKeybindings, visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { @@ -248,6 +249,29 @@ describe("TreeSelectorComponent", () => { }); }); + describe("help", () => { + test("renders semantic help rows without truncating narrow terminal controls", () => { + const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; + const tree = buildTree(entries); + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + + const plainLines = selector.render(30).map(stripVTControlCharacters); + const plain = plainLines.join("\n"); + expect(plain).toContain("branch"); + expect(plain).toContain("filters"); + expect(plain).toContain("cycle"); + expect(plain).toContain("label time"); + expect(plain).not.toContain("..."); + expect(plainLines.every((line) => visibleWidth(line) <= 30)).toBe(true); + }); + }); + describe("label timestamps", () => { test("toggles label timestamps for labeled nodes", () => { const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index a8bdbb2a..80f3d46c 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from ".. function createContext(tokens: number | null, compact = vi.fn()): ExtensionContext { return { + mode: "print", hasUI: false, ui: {} as ExtensionContext["ui"], cwd: process.cwd(), @@ -11,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte modelRegistry: {} as ExtensionContext["modelRegistry"], model: undefined, isIdle: () => true, + isProjectTrusted: () => true, signal: undefined, abort: vi.fn(), hasPendingMessages: () => false, diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts new file mode 100644 index 00000000..01500bac --- /dev/null +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -0,0 +1,67 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts"; + +describe("ProjectTrustStore", () => { + let tempDir: string; + let agentDir: string; + let cwd: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + cwd = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stores decisions and inherits from parent directories", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + mkdirSync(childDir, { recursive: true }); + + expect(store.get(childDir)).toBeNull(); + store.set(parentDir, true); + expect(store.get(childDir)).toBe(true); + store.set(childDir, false); + expect(store.get(childDir)).toBe(false); + store.set(childDir, null); + expect(store.get(childDir)).toBe(true); + }); + + it("detects trust-requiring project resources", () => { + const originalHome = process.env.HOME; + process.env.HOME = tempDir; + try { + mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true }); + mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(false); + expect(hasTrustRequiringProjectResources(cwd)).toBe(false); + + writeFileSync(join(tempDir, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(true); + rmSync(join(tempDir, ".pi", "settings.json"), { force: true }); + + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); + + rmSync(join(cwd, ".pi"), { recursive: true, force: true }); + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } + }); +}); diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts new file mode 100644 index 00000000..33be9a97 --- /dev/null +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -0,0 +1,87 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +describe("TrustSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("marks the saved trusted decision", () => { + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: { path: "/project", decision: true }, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted (/project)"); + expect(output).toContain("Current session: trusted"); + expect(output).toContain("Trust ✓"); + expect(output).not.toContain("Do not trust ✓"); + }); + + it("selects a trust decision", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: null, + projectTrusted: false, + onSelect, + onCancel: () => {}, + }); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] }); + }); + + it("labels saved ancestor decisions as inherited", () => { + const selector = new TrustSelectorComponent({ + cwd: "/parent/project/nested", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + }); + + it("adds a trust parent option", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/parent/project", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + expect(output).toContain("Trust parent folder (/parent) ✓"); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith({ + trusted: true, + updates: [ + { path: "/parent", decision: true }, + { path: "/parent/project", decision: null }, + ], + }); + }); +}); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index 70197fa5..b294c627 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -29,6 +29,7 @@ describe("version checks", () => { expect(comparePackageVersions("0.70.6", "0.70.5")).toBeGreaterThan(0); expect(comparePackageVersions("0.70.5", "0.70.5")).toBe(0); expect(comparePackageVersions("0.70.4", "0.70.5")).toBeLessThan(0); + expect(comparePackageVersions("5.0.0-beta.20", "5.0.0-beta.9")).toBeGreaterThan(0); expect(isNewerPackageVersion("0.70.5", "0.70.5")).toBe(false); expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true); }); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 3437fd70..15a2436c 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -2,6 +2,14 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", + "paths": { + "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], + "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"], + "@earendil-works/pi-tui": ["../tui/dist/index.d.ts"], + "@earendil-works/pi-tui/*": ["../tui/dist/*.d.ts", "../tui/dist/components/*.d.ts"] + }, "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.d.ts"], diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index d3857107..5759e3c4 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -2,8 +2,12 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); +const aiSrcBase = fileURLToPath(new URL("../ai/src/base.ts", import.meta.url)); const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url)); +const aiOpenRouterImages = fileURLToPath(new URL("../ai/src/providers/images/openrouter.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); +const agentSrcBase = fileURLToPath(new URL("../agent/src/base.ts", import.meta.url)); +const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url)); export default defineConfig({ test: { @@ -19,11 +23,19 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@earendil-works\/pi-ai\/base$/, replacement: aiSrcBase }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@earendil-works\/pi-ai\/openrouter-images$/, replacement: aiOpenRouterImages }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-agent-core\/base$/, replacement: agentSrcBase }, + { find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex }, { find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@mariozechner\/pi-ai\/base$/, replacement: aiSrcBase }, { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@mariozechner\/pi-ai\/openrouter-images$/, replacement: aiOpenRouterImages }, { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@mariozechner\/pi-agent-core\/base$/, replacement: agentSrcBase }, + { find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex }, ], }, }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b1f8a8dc..96ff6d5c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,114 @@ ## [Unreleased] +## [0.79.9] - 2026-06-20 + +### Fixed + +- Fixed Markdown streaming code fence rendering so partial closing fences no longer make code blocks shrink or flicker while content streams ([#5846](https://github.com/earendil-works/pi/pull/5846) by [@xl0](https://github.com/xl0)). + +## [0.79.8] - 2026-06-19 + +## [0.79.7] - 2026-06-18 + +### Added + +- Added terminal color-scheme query and notification support for light/dark appearance detection (`TUI.queryTerminalColorScheme()`, `TUI.onTerminalColorSchemeChange()`, and `TUI.setTerminalColorSchemeNotifications()`) ([#5874](https://github.com/earendil-works/pi/pull/5874)). +- Added Warp terminal detection for Kitty graphics inline image support ([#5841](https://github.com/earendil-works/pi/pull/5841) by [@dodiego](https://github.com/dodiego)). +- Exported `sliceByColumn` for ANSI-aware horizontal column slicing. + +## [0.79.6] - 2026-06-16 + +## [0.79.5] - 2026-06-16 + +### Changed + +- Updated Markdown parsing to `marked` 18.0.5. + +### Fixed + +- Fixed editor Cursor Up handling so non-empty drafts jump to the start of the line before browsing input history ([#5789](https://github.com/earendil-works/pi/pull/5789) by [@4h9fbZ](https://github.com/4h9fbZ)). + +## [0.79.4] - 2026-06-15 + +### Added + +- Added terminal background color query support for OSC 11 replies ([#5385](https://github.com/earendil-works/pi/pull/5385) by [@vegarsti](https://github.com/vegarsti)). + +### Fixed + +- Fixed overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)). +- Fixed WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). + +## [0.79.3] - 2026-06-13 + +## [0.79.2] - 2026-06-12 + +### Fixed + +- Fixed Markdown source list marker preservation to include unordered markers, so standalone `+` user messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). +- Fixed slash-separated fuzzy queries so provider/model completions remain matchable after insertion. +- Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). +- Fixed editor wrapping for CJK text to break at character boundaries instead of leaving large trailing gaps ([#5585](https://github.com/earendil-works/pi/pull/5585) by [@haoqixu](https://github.com/haoqixu)). +- Fixed loose Markdown list rendering to preserve blank-line separation between list items ([#5562](https://github.com/earendil-works/pi/pull/5562) by [@Perlence](https://github.com/Perlence)). + +## [0.79.1] - 2026-06-09 + +### Added + +- Added `AutocompleteProvider.triggerCharacters` so editor autocomplete can naturally trigger on provider-defined token prefixes ([#4703](https://github.com/earendil-works/pi/issues/4703)). + +### Fixed + +- Fixed IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). +- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). +- Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). + +## [0.79.0] - 2026-06-08 + +### Fixed + +- Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). +- Fixed intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). +- Fixed TUI rendering to clear stale lines when content shrinks to zero. +- Fixed autocomplete suggestions to re-query after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). + +## [0.78.1] - 2026-06-04 + +### Fixed + +- Fixed overlay focus restoration so non-capturing overlays remain interactive after UI rerenders and explicit focus release ([#5235](https://github.com/earendil-works/pi/pull/5235) by [@nicobailon](https://github.com/nicobailon)). +- Fixed tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)). + +## [0.78.0] - 2026-05-29 + +### Fixed + +- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi-mono/issues/5185)). +- Clarified the IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi-mono/issues/5200)). +- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). + +## [0.77.0] - 2026-05-28 + +### Fixed + +- Fixed keyboard protocol negotiation to ignore mismatched or delayed terminal responses, avoiding false Kitty keyboard protocol detection ([#5091](https://github.com/earendil-works/pi/pull/5091) by [@mitsuhiko](https://github.com/mitsuhiko)). + +## [0.76.0] - 2026-05-27 + +### Added + +- Added an opt-in Markdown renderer option to preserve source ordered-list markers for transcript rendering ([#5013](https://github.com/earendil-works/pi/issues/5013)). + +### Fixed + +- Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. +- Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). +- Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)). +- Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)). + +## [0.75.5] - 2026-05-23 + ### Changed - Replaced the optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)). diff --git a/packages/tui/README.md b/packages/tui/README.md index 5a151afb..d0aae400 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -116,9 +116,21 @@ handle.setHidden(true); // Temporarily hide (can show again) handle.setHidden(false); // Show again after hiding handle.isHidden(); // Check if temporarily hidden handle.focus(); // Focus and bring to visual front -handle.unfocus(); // Release focus to previous target +handle.unfocus(); // Release focus to normal fallback +handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component +handle.unfocus({ target: null }); // Release this overlay and leave focus empty handle.isFocused(); // Check if overlay has focus +handle.unfocus(); +// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target. + +handle.unfocus({ target: null }); +// Overlay loses focus; no component receives input until focus is set again. + +// A focused visible overlay reclaims keyboard input after temporary replacement UI +// releases focus. If you want a specific component to receive input while overlays remain +// visible, call handle.unfocus({ target: component }). + // Hide topmost overlay tui.hideOverlay(); @@ -176,9 +188,9 @@ When a `Focusable` component has focus, TUI: 1. Sets `focused = true` on the component 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence) 3. Positions the hardware terminal cursor at that location -4. Shows the hardware cursor +4. Shows the hardware cursor only when `showHardwareCursor` is enabled -This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface. +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child: diff --git a/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node new file mode 100755 index 00000000..46148426 Binary files /dev/null and b/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node differ diff --git a/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node new file mode 100755 index 00000000..0c82b391 Binary files /dev/null and b/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node differ diff --git a/packages/tui/native/darwin/src/darwin-modifiers.c b/packages/tui/native/darwin/src/darwin-modifiers.c new file mode 100644 index 00000000..7e612519 --- /dev/null +++ b/packages/tui/native/darwin/src/darwin-modifiers.c @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include + +#define NAPI_AUTO_LENGTH ((size_t)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*); +typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*); +typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); + +static void* node_symbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +static CGEventFlags modifier_mask_for_name(const char* name) { + if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift; + if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand; + if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl; + if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate; + return 0; +} + +static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + bool pressed = false; + if (napi_get_cb_info && napi_get_value_string_utf8) { + size_t argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) { + char name[16] = {0}; + size_t copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + CGEventFlags mask = modifier_mask_for_name(name); + if (mask != 0) { + CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); + pressed = (flags & mask) != 0; + } + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) { + napi_set_named_property(env, exports, "isModifierPressed", fn); + } + + return exports; +} diff --git a/packages/tui/package.json b/packages/tui/package.json index c684eae7..d9d53e0e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.75.4", + "version": "0.79.9", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", @@ -13,6 +13,7 @@ "files": [ "dist/**/*", "native/win32/prebuilds/**/*.node", + "native/darwin/prebuilds/**/*.node", "README.md" ], "keywords": [ @@ -28,7 +29,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/tui" }, "engines": { @@ -37,11 +38,10 @@ "types": "./dist/index.d.ts", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "devDependencies": { "@xterm/headless": "5.5.0", - "@xterm/xterm": "5.5.0", "chalk": "5.6.2" } } diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 5408967d..205748d8 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -239,6 +239,9 @@ export interface AutocompleteSuggestions { } export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0b30ad0a..bedd0105 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,10 +4,19 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { + cjkBreakRegex, + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + truncateToWidth, + visibleWidth, +} from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; -const baseSegmenter = getSegmenter(); +const graphemeSegmenter = getGraphemeSegmenter(); +const wordSegmenter = getWordSegmenter(); /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; @@ -27,7 +36,11 @@ function isPasteMarker(segment: string): boolean { * * Only markers whose numeric ID exists in `validIds` are merged. */ -function segmentWithMarkers(text: string, validIds: Set): Iterable { +function segmentWithMarkers( + text: string, + baseSegmenter: Intl.Segmenter, + validIds: Set, +): Iterable { // Fast path: no paste markers in the text or no valid IDs. if (validIds.size === 0 || !text.includes("[paste #")) { return baseSegmenter.segment(text); @@ -109,7 +122,7 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl } const chunks: TextChunk[] = []; - const segments = preSegmented ?? [...baseSegmenter.segment(line)]; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; let currentWidth = 0; let chunkStart = 0; @@ -168,13 +181,21 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl // Advance. currentWidth += gWidth; - // Record wrap opportunity: whitespace followed by non-whitespace. - // Multiple spaces join (no break between them); the break point is - // after the last space before the next word. + // Record wrap opportunity: whitespace followed by non-whitespace + // (multiple spaces join; the break point is after the last space), + // or at a boundary where either side is CJK (CJK allows breaking + // between any adjacent characters). const next = segments[i + 1]; if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) { wrapOppIndex = next.index; wrapOppWidth = currentWidth; + } else if (!isWs && next && !isWhitespaceChar(next.segment)) { + const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme); + const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment); + if (isCjk || nextIsCjk) { + wrapOppIndex = next.index; + wrapOppWidth = currentWidth; + } } } @@ -213,6 +234,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { }; const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; + +function escapeCharacterClass(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); +} + +function buildTriggerPattern(triggerCharacters: string[]): RegExp { + return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); +} + +function buildDebouncePattern(triggerCharacters: string[]): RegExp { + const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); + return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); +} export class Editor implements Component, Focusable { private state: EditorState = { @@ -239,6 +274,9 @@ export class Editor implements Component, Focusable { // Autocomplete support private autocompleteProvider?: AutocompleteProvider; + private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); + private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; @@ -260,6 +298,7 @@ export class Editor implements Component, Focusable { // Prompt history for up/down navigation private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + private historyDraft: EditorState | null = null; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -301,8 +340,8 @@ export class Editor implements Component, Focusable { } /** Segment text with paste-marker awareness, only merging markers with valid IDs. */ - private segment(text: string): Iterable { - return segmentWithMarkers(text, this.validPasteIds()); + private segment(text: string, mode: "word" | "grapheme"): Iterable { + return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds()); } getPaddingX(): number { @@ -332,6 +371,7 @@ export class Editor implements Component, Focusable { setAutocompleteProvider(provider: AutocompleteProvider): void { this.cancelAutocomplete(); this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); } /** @@ -376,24 +416,39 @@ export class Editor implements Component, Focusable { // Capture state when first entering history browsing mode if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); + this.historyDraft = structuredClone(this.state); } this.historyIndex = newIndex; if (this.historyIndex === -1) { - // Returned to "current" state - clear editor - this.setTextInternal(""); + const draft = this.historyDraft; + this.historyDraft = null; + if (draft) { + this.state = draft; + this.preferredVisualCol = null; + this.snappedFromCursorCol = null; + this.scrollOffset = 0; + if (this.onChange) this.onChange(this.getText()); + } else { + this.setTextInternal(""); + } } else { - this.setTextInternal(this.history[this.historyIndex] || ""); + this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); } } + private exitHistoryBrowsing(): void { + this.historyIndex = -1; + this.historyDraft = null; + } + /** Internal setText that doesn't reset history state - used by navigateHistory */ - private setTextInternal(text: string): void { + private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { const lines = text.split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; - this.state.cursorLine = this.state.lines.length - 1; - this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); + this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1; + this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0); // Reset scroll - render() will adjust to show cursor this.scrollOffset = 0; @@ -463,8 +518,10 @@ export class Editor implements Component, Focusable { } // Render each visible layout line - // Emit hardware cursor marker only when focused and not showing autocomplete - const emitCursorMarker = this.focused && !this.autocompleteState; + // Emit hardware cursor marker when focused so TUI can position the + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; for (const layoutLine of visibleLines) { let displayText = layoutLine.text; @@ -482,7 +539,7 @@ export class Editor implements Component, Focusable { if (after.length > 0) { // Cursor is on a character (grapheme) - replace it with highlighted version // Get the first grapheme from 'after' - const afterGraphemes = [...this.segment(after)]; + const afterGraphemes = [...this.segment(after, "grapheme")]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; @@ -750,9 +807,10 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - if (this.isEditorEmpty()) { - this.navigateHistory(-1); - } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { + if ( + this.isOnFirstVisualLine() && + (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + ) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line @@ -855,7 +913,7 @@ export class Editor implements Component, Focusable { } } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]); + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; @@ -940,7 +998,7 @@ export class Editor implements Component, Focusable { setText(text: string): void { this.cancelAutocomplete(); this.lastAction = null; - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { @@ -959,7 +1017,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.pushUndoSnapshot(); this.lastAction = null; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.insertTextAtCursorInternal(text); } @@ -1022,7 +1080,7 @@ export class Editor implements Component, Focusable { // All the editor methods from before... private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit @@ -1054,8 +1112,8 @@ export class Editor implements Component, Focusable { if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } - // Auto-trigger for symbol-based completion like @ or # at token boundaries - else if (char === "@" || char === "#") { + // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries + else if (this.autocompleteTriggerCharacters.includes(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; @@ -1071,8 +1129,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in a symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Check if we're in a symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1083,7 +1141,7 @@ export class Editor implements Component, Focusable { private handlePaste(pastedText: string): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1151,7 +1209,7 @@ export class Editor implements Component, Focusable { private addNewLine(): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1192,7 +1250,7 @@ export class Editor implements Component, Focusable { this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); this.lastAction = null; @@ -1202,7 +1260,7 @@ export class Editor implements Component, Focusable { } private handleBackspace(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; if (this.state.cursorCol > 0) { @@ -1213,7 +1271,7 @@ export class Editor implements Component, Focusable { const beforeCursor = line.slice(0, this.state.cursorCol); // Find the last grapheme in the text before cursor - const graphemes = [...this.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor, "grapheme")]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; @@ -1251,8 +1309,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1314,7 +1372,7 @@ export class Editor implements Component, Focusable { // Snap cursor to atomic segment boundary (e.g. paste markers) // so the cursor never lands in the middle of a multi-grapheme unit. // Single-grapheme segments don't need snapping. - const segments = [...this.segment(logicalLine)]; + const segments = [...this.segment(logicalLine, "grapheme")]; for (const seg of segments) { if (seg.index > this.state.cursorCol) break; if (seg.segment.length <= 1) continue; @@ -1419,7 +1477,7 @@ export class Editor implements Component, Focusable { } private deleteToStartOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1454,7 +1512,7 @@ export class Editor implements Component, Focusable { } private deleteToEndOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1486,7 +1544,7 @@ export class Editor implements Component, Focusable { } private deleteWordBackwards(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1531,7 +1589,7 @@ export class Editor implements Component, Focusable { } private deleteWordForward(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1573,7 +1631,7 @@ export class Editor implements Component, Focusable { } private handleForwardDelete(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1585,7 +1643,7 @@ export class Editor implements Component, Focusable { const afterCursor = currentLine.slice(this.state.cursorCol); // Find the first grapheme at cursor - const graphemes = [...this.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor, "grapheme")]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; @@ -1615,8 +1673,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1642,7 +1700,7 @@ export class Editor implements Component, Focusable { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, width, [...this.segment(line)]); + const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); for (const chunk of chunks) { visualLines.push({ logicalLine: i, @@ -1707,7 +1765,7 @@ export class Editor implements Component, Focusable { // Moving right - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol < currentLine.length) { const afterCursor = currentLine.slice(this.state.cursorCol); - const graphemes = [...this.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor, "grapheme")]; const firstGrapheme = graphemes[0]; this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { @@ -1725,7 +1783,7 @@ export class Editor implements Component, Focusable { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol > 0) { const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...this.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor, "grapheme")]; const lastGrapheme = graphemes[graphemes.length - 1]; this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { @@ -1736,6 +1794,18 @@ export class Editor implements Component, Focusable { } } } + + // Keep an open autocomplete picker in sync with the new cursor + // position: cursor movement changes the text before the cursor, so a + // picker computed for the old position is stale. Re-query so it + // refreshes — or closes when the new position yields no suggestions — + // mirroring insertCharacter()/handleBackspace(). Without this, arrowing + // left from `/cmd ` back into the command name leaves the argument + // picker showing against a `/cmd` prefix (and a Tab there would + // concatenate the stale suggestion onto the partial command name). + if (this.autocompleteState) { + this.updateAutocomplete(); + } } /** @@ -1768,47 +1838,12 @@ export class Editor implements Component, Focusable { return; } - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...this.segment(textBeforeCursor)]; - let newCol = this.state.cursorCol; - - // Skip trailing whitespace - while ( - graphemes.length > 0 && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") && - isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") - ) { - newCol -= graphemes.pop()?.segment.length || 0; - } - - if (graphemes.length > 0) { - const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; - if (isPasteMarker(lastGrapheme)) { - // Paste marker is a single atomic word - newCol -= graphemes.pop()?.segment.length || 0; - } else if (isPunctuationChar(lastGrapheme)) { - // Skip punctuation run - while ( - graphemes.length > 0 && - isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") - ) { - newCol -= graphemes.pop()?.segment.length || 0; - } - } else { - // Skip word run - while ( - graphemes.length > 0 && - !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") - ) { - newCol -= graphemes.pop()?.segment.length || 0; - } - } - } - - this.setCursorCol(newCol); + this.setCursorCol( + findWordBackward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); } /** @@ -1852,7 +1887,7 @@ export class Editor implements Component, Focusable { * Insert text at cursor position (used by yank operations). */ private insertYankedText(text: string): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const lines = text.split("\n"); if (lines.length === 1) { @@ -1937,7 +1972,7 @@ export class Editor implements Component, Focusable { } private undo(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot); @@ -1995,44 +2030,12 @@ export class Editor implements Component, Focusable { return; } - const textAfterCursor = currentLine.slice(this.state.cursorCol); - const segments = this.segment(textAfterCursor); - const iterator = segments[Symbol.iterator](); - let next = iterator.next(); - let newCol = this.state.cursorCol; - - // Skip leading whitespace - while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) { - newCol += next.value.segment.length; - next = iterator.next(); - } - - if (!next.done) { - const firstGrapheme = next.value.segment; - if (isPasteMarker(firstGrapheme)) { - // Paste marker is a single atomic word - newCol += firstGrapheme.length; - } else if (isPunctuationChar(firstGrapheme)) { - // Skip punctuation run - while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) { - newCol += next.value.segment.length; - next = iterator.next(); - } - } else { - // Skip word run - while ( - !next.done && - !isWhitespaceChar(next.value.segment) && - !isPunctuationChar(next.value.segment) && - !isPasteMarker(next.value.segment) - ) { - newCol += next.value.segment.length; - next = iterator.next(); - } - } - } - - this.setCursorCol(newCol); + this.setCursorCol( + findWordForward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); } // Slash menu only allowed on the first line of the editor @@ -2169,6 +2172,19 @@ export class Editor implements Component, Focusable { await this.autocompleteRequestTask; } + private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void { + const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + for (const character of triggerCharacters) { + if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) { + continue; + } + next.push(character); + } + this.autocompleteTriggerCharacters = next; + this.autocompleteTriggerPattern = buildTriggerPattern(next); + this.autocompleteDebouncePattern = buildDebouncePattern(next); + } + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { if (options.explicitTab || options.force) { return 0; @@ -2176,8 +2192,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); - return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; } private async runAutocompleteRequest( diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 71f2363b..a054076d 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -3,9 +3,10 @@ import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; -const segmenter = getSegmenter(); +const segmenter = getGraphemeSegmenter(); interface InputState { value: string; @@ -347,72 +348,15 @@ export class Input implements Component, Focusable { } private moveWordBackwards(): void { - if (this.cursor === 0) { - return; - } - + if (this.cursor === 0) return; this.lastAction = null; - const textBeforeCursor = this.value.slice(0, this.cursor); - const graphemes = [...segmenter.segment(textBeforeCursor)]; - - // Skip trailing whitespace - while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - - if (graphemes.length > 0) { - const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; - if (isPunctuationChar(lastGrapheme)) { - // Skip punctuation run - while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - } else { - // Skip word run - while ( - graphemes.length > 0 && - !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") - ) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - } - } + this.cursor = findWordBackward(this.value, this.cursor); } private moveWordForwards(): void { - if (this.cursor >= this.value.length) { - return; - } - + if (this.cursor >= this.value.length) return; this.lastAction = null; - const textAfterCursor = this.value.slice(this.cursor); - const segments = segmenter.segment(textAfterCursor); - const iterator = segments[Symbol.iterator](); - let next = iterator.next(); - - // Skip leading whitespace - while (!next.done && isWhitespaceChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - - if (!next.done) { - const firstGrapheme = next.value.segment; - if (isPunctuationChar(firstGrapheme)) { - // Skip punctuation run - while (!next.done && isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - } else { - // Skip word run - while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - } - } + this.cursor = findWordForward(this.value, this.cursor); } private handlePaste(pastedText: string): void { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 2f0ab68d..1034cb47 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -22,6 +22,31 @@ class StrictStrikethroughTokenizer extends Tokenizer { } } +function trimPartialClosingFences(tokens: readonly Token[]): void { + const token = tokens[tokens.length - 1]; + if (token?.type === "list") { + trimPartialClosingFences(token.items[token.items.length - 1]?.tokens ?? []); + return; + } + if (token?.type === "blockquote") { + trimPartialClosingFences(token.tokens ?? []); + return; + } + if (token?.type !== "code") { + return; + } + + // Trim streamed partial closing fences so code blocks do not shrink/flicker + // when the final fence character arrives. See https://github.com/earendil-works/pi/issues/5825. + const marker = /^(`{3,}|~{3,})/.exec(token.raw)?.[1]; + const lastLine = token.raw.split("\n").pop(); + if (!marker || !lastLine || lastLine.length >= marker.length || lastLine !== marker[0]?.repeat(lastLine.length)) { + return; + } + + token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, ""); +} + const markdownParser = new Marked(); markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer(), @@ -70,6 +95,11 @@ export interface MarkdownTheme { codeBlockIndent?: string; } +export interface MarkdownOptions { + /** Preserve source list markers instead of normalizing them. */ + preserveOrderedListMarkers?: boolean; +} + interface InlineStyleContext { applyText: (text: string) => string; stylePrefix: string; @@ -81,6 +111,7 @@ export class Markdown implements Component { private paddingY: number; // Top/bottom padding private defaultTextStyle?: DefaultTextStyle; private theme: MarkdownTheme; + private options: MarkdownOptions; private defaultStylePrefix?: string; // Cache for rendered output @@ -94,12 +125,14 @@ export class Markdown implements Component { paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, ) { this.text = text; this.paddingX = paddingX; this.paddingY = paddingY; this.theme = theme; this.defaultTextStyle = defaultTextStyle; + this.options = options ? { ...options } : {}; } setText(text: string): void { @@ -137,6 +170,7 @@ export class Markdown implements Component { // Parse markdown to HTML-like tokens const tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); // Convert tokens to styled terminal output const renderedLines: string[] = []; @@ -548,6 +582,16 @@ export class Markdown implements Component { return result; } + private getOrderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + + private getUnorderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})([-+*])(?:[ \t]+|(?=\r?\n|$))/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + /** * Render a list with proper nesting support */ @@ -559,7 +603,14 @@ export class Markdown implements Component { for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; - const bullet = token.ordered ? `${startNumber + i}. ` : "- "; + const isLastItem = i === token.items.length - 1; + const bullet = token.ordered + ? this.options.preserveOrderedListMarkers + ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) + : `${startNumber + i}. ` + : this.options.preserveOrderedListMarkers + ? (this.getUnorderedListMarker(item) ?? "- ") + : "- "; const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; const marker = bullet + taskMarker; const firstPrefix = indent + this.theme.listBullet(marker); @@ -587,6 +638,10 @@ export class Markdown implements Component { if (!renderedAnyLine) { lines.push(firstPrefix); } + + if (token.loose && !isLastItem) { + lines.push(""); + } } return lines; diff --git a/packages/tui/src/fuzzy.ts b/packages/tui/src/fuzzy.ts index 35df468a..73c10dcf 100644 --- a/packages/tui/src/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -94,7 +94,7 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch { /** * Filter and sort items by fuzzy match quality (best matches first). - * Supports space-separated tokens: all tokens must match. + * Supports whitespace- and slash-separated tokens: all tokens must match. */ export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { if (!query.trim()) { @@ -103,7 +103,7 @@ export function fuzzyFilter(items: T[], query: string, getText: (item: T) => const tokens = query .trim() - .split(/\s+/) + .split(/[\s/]+/) .filter((t) => t.length > 0); if (tokens.length === 0) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 9404aa65..4e76b107 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,7 +15,7 @@ export { Editor, type EditorOptions, type EditorTheme } from "./components/edito export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; -export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.ts"; +export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; export { type SelectItem, SelectList, @@ -61,6 +61,13 @@ export { export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; +// Terminal colors +export { + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; // Terminal image support export { allocateImageId, @@ -99,8 +106,9 @@ export { type OverlayHandle, type OverlayMargin, type OverlayOptions, + type OverlayUnfocusOptions, type SizeValue, TUI, } from "./tui.ts"; // Utilities -export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; +export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts new file mode 100644 index 00000000..e2cd631c --- /dev/null +++ b/packages/tui/src/native-modifiers.ts @@ -0,0 +1,59 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cjsRequire = createRequire(import.meta.url); + +export type ModifierKey = "shift" | "command" | "control" | "option"; + +type NativeModifiersHelper = { + isModifierPressed: (name: ModifierKey) => boolean; +}; + +let nativeModifiersHelper: NativeModifiersHelper | null | undefined; + +function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper { + if (typeof value !== "object" || value === null) return false; + const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed; + return typeof candidate === "function"; +} + +function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { + if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; + nativeModifiersHelper = null; + if (process.platform !== "darwin") return undefined; + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return undefined; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as unknown; + if (isNativeModifiersHelper(helper)) { + nativeModifiersHelper = helper; + return helper; + } + } catch { + // Try the next possible packaging location. + } + } + + return undefined; +} + +export function isNativeModifierPressed(key: ModifierKey): boolean { + const helper = loadNativeModifiersHelper(); + if (!helper) return false; + try { + return helper.isModifierPressed(key) === true; + } catch { + return false; + } +} diff --git a/packages/tui/src/terminal-colors.ts b/packages/tui/src/terminal-colors.ts new file mode 100644 index 00000000..cff6dc8e --- /dev/null +++ b/packages/tui/src/terminal-colors.ts @@ -0,0 +1,73 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +export type TerminalColorScheme = "dark" | "light"; + +function hexToRgb(hex: string): RgbColor { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return { r, g, b }; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; +const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/; + +export function isOsc11BackgroundColorResponse(data: string): boolean { + return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); + if (!match) { + return undefined; + } + + const value = match[1].trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} + +export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined { + const match = data.match(COLOR_SCHEME_REPORT_PATTERN); + if (!match) { + return undefined; + } + return match[1] === "2" ? "light" : "dark"; +} diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index d6fe13bd..8854a86a 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { @@ -39,18 +41,42 @@ export function setCellDimensions(dims: CellDimensions): void { cellDimensions = dims; } -export function detectCapabilities(): TerminalCapabilities { +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; + const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; - // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps - // sequences differently). Force hyperlinks off whenever we detect them, even - // when the outer terminal would otherwise support OSC 8. Image protocols are - // also unreliable under tmux/screen, so leave `images: null` for safety. - const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"); - if (inTmuxOrScreen) { + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env.TMUX || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } @@ -66,10 +92,19 @@ export function detectCapabilities(): TerminalCapabilities { return { images: "kitty", trueColor: true, hyperlinks: true }; } + // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks. + if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") { return { images: "iterm2", trueColor: true, hyperlinks: true }; } + if (process.env.WT_SESSION) { + return { images: null, trueColor: true, hyperlinks: true }; + } + if (termProgram === "vscode") { return { images: null, trueColor: true, hyperlinks: true }; } @@ -78,11 +113,15 @@ export function detectCapabilities(): TerminalCapabilities { return { images: null, trueColor: true, hyperlinks: true }; } + if (terminalEmulator === "jetbrains-jediterm") { + return { images: null, trueColor: true, hyperlinks: false }; + } + // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we // have positively identified a hyperlink-capable terminal above. - return { images: null, trueColor: hasTrueColorHint || !!process.env.WT_SESSION, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } export function getCapabilities(): TerminalCapabilities { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 94c56ff6..2542286a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; +import { isNativeModifierPressed } from "./native-modifiers.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -10,6 +11,40 @@ const cjsRequire = createRequire(import.meta.url); const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; +const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; + +export type KeyboardProtocolNegotiationSequence = + | { type: "kitty-flags"; flags: number } + | { type: "device-attributes" }; + +export function parseKeyboardProtocolNegotiationSequence( + sequence: string, +): KeyboardProtocolNegotiationSequence | undefined { + const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/); + if (kittyFlags) { + return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) }; + } + if (/^\x1b\[\?[\d;]*c$/.test(sequence)) { + return { type: "device-attributes" }; + } + return undefined; +} + +function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean { + return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); +} + +export function isAppleTerminalSession(): boolean { + return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; +} + +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; + return data; +} /** * Minimal terminal interface for TUI @@ -67,6 +102,9 @@ export class ProcessTerminal implements Terminal { private resizeHandler?: () => void; private _kittyProtocolActive = false; private _modifyOtherKeysActive = false; + private keyboardProtocolPushed = false; + private keyboardProtocolNegotiationBuffer = ""; + private keyboardProtocolBufferFlushTimer?: ReturnType; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; private progressInterval?: ReturnType; @@ -89,6 +127,10 @@ export class ProcessTerminal implements Terminal { return this._kittyProtocolActive; } + get modifyOtherKeysActive(): boolean { + return this._modifyOtherKeysActive; + } + start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; this.resizeHandler = onResize; @@ -119,8 +161,7 @@ export class ProcessTerminal implements Terminal { // since that resets console mode flags. this.enableWindowsVTInput(); - // Query and enable Kitty keyboard protocol - // The query handler intercepts input temporarily, then installs the user's handler + // Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response. // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ this.queryAndEnableKittyProtocol(); } @@ -136,31 +177,18 @@ export class ProcessTerminal implements Terminal { private setupStdinBuffer(): void { this.stdinBuffer = new StdinBuffer({ timeout: 10 }); - // Kitty protocol response pattern: \x1b[?u - const kittyResponsePattern = /^\x1b\[\?(\d+)u$/; - // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { - // Check for Kitty protocol response (only if not already enabled) - if (!this._kittyProtocolActive) { - const match = sequence.match(kittyResponsePattern); - if (match) { - this._kittyProtocolActive = true; - setKittyProtocolActive(true); - - // Enable Kitty keyboard protocol (push flags) - // Flag 1 = disambiguate escape codes - // Flag 2 = report event types (press/repeat/release) - // Flag 4 = report alternate keys (shifted key, base layout key) - // Base layout key enables shortcuts to work with non-Latin keyboard layouts - process.stdout.write("\x1b[>7u"); - return; // Don't forward protocol response to TUI - } + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence === "pending") { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); + return; // Wait briefly for the rest of a split Kitty response. + } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; } - if (this.inputHandler) { - this.inputHandler(sequence); - } + this.forwardInputSequence(sequence); }); // Re-wrap paste content with bracketed paste markers for existing editor handling @@ -177,29 +205,128 @@ export class ProcessTerminal implements Terminal { } /** - * Query terminal for Kitty keyboard protocol support and enable if available. + * Query terminal for Kitty keyboard protocol support and enable it if available. * - * Sends CSI ? u to query current flags. If terminal responds with CSI ? u, - * it supports the protocol and we enable it with CSI > 1 u. + * Kitty's progressive enhancement detection requires requesting the desired + * flags before querying them. The trailing DA query is a sentinel supported by + * terminals that do not know Kitty keyboard protocol; receiving DA before a + * Kitty response enables modifyOtherKeys fallback without a startup timeout. * - * If no Kitty response arrives shortly after startup, fall back to enabling - * xterm modifyOtherKeys mode 2. This is needed for tmux, which can forward - * modified enter keys as CSI-u when extended-keys is enabled, but may not - * answer the Kitty protocol query. - * - * The response is detected in setupStdinBuffer's data handler, which properly - * handles the case where the response arrives split across multiple stdin events. + * The requested flags are: + * - 1 = disambiguate escape codes + * - 2 = report event types (press/repeat/release) + * - 4 = report alternate keys (shifted key, base layout key) */ private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); - process.stdout.write("\x1b[?u"); - setTimeout(() => { - if (!this._kittyProtocolActive && !this._modifyOtherKeysActive) { - process.stdout.write("\x1b[>4;2m"); - this._modifyOtherKeysActive = true; + this.keyboardProtocolPushed = true; + this.clearKeyboardProtocolNegotiationBuffer(); + process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + } + + private handleKeyboardProtocolNegotiationSequence( + negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, + ): boolean { + if (!negotiationSequence) return false; + this.clearKeyboardProtocolNegotiationBuffer(); + if (negotiationSequence.type === "kitty-flags") { + if (negotiationSequence.flags !== 0) { + this.disableModifyOtherKeys(); + if (!this._kittyProtocolActive) { + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + } + } else { + this.enableModifyOtherKeys(); } - }, 150); + return true; + } + + if (!this._kittyProtocolActive) { + this.enableModifyOtherKeys(); + } + return true; + } + + private readKeyboardProtocolNegotiationSequence( + sequence: string, + ): KeyboardProtocolNegotiationSequence | "pending" | undefined { + if (this.keyboardProtocolNegotiationBuffer) { + const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence); + if (negotiationSequence) { + this.clearKeyboardProtocolNegotiationBuffer(); + return negotiationSequence; + } + if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) { + this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); + return "pending"; + } + this.flushKeyboardProtocolNegotiationBufferAsInput(); + } + + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence) return negotiationSequence; + if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) { + this.setKeyboardProtocolNegotiationBuffer(sequence); + return "pending"; + } + return undefined; + } + + private setKeyboardProtocolNegotiationBuffer(sequence: string): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = sequence; + } + + private clearKeyboardProtocolNegotiationBuffer(): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = ""; + } + + private flushKeyboardProtocolNegotiationBufferAsInput(): void { + if (!this.keyboardProtocolNegotiationBuffer) return; + const sequence = this.keyboardProtocolNegotiationBuffer; + this.clearKeyboardProtocolNegotiationBuffer(); + this.forwardInputSequence(sequence); + } + + private scheduleKeyboardProtocolNegotiationBufferFlush(): void { + if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return; + this.keyboardProtocolBufferFlushTimer = setTimeout(() => { + this.keyboardProtocolBufferFlushTimer = undefined; + this.flushKeyboardProtocolNegotiationBufferAsInput(); + }, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS); + } + + private clearKeyboardProtocolNegotiationBufferFlushTimer(): void { + if (!this.keyboardProtocolBufferFlushTimer) return; + clearTimeout(this.keyboardProtocolBufferFlushTimer); + this.keyboardProtocolBufferFlushTimer = undefined; + } + + private forwardInputSequence(sequence: string): void { + if (!this.inputHandler) return; + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); + } + + private enableModifyOtherKeys(): void { + if (this._kittyProtocolActive || this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;2m"); + this._modifyOtherKeysActive = true; + } + + private disableModifyOtherKeys(): void { + if (!this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;0m"); + this._modifyOtherKeysActive = false; } /** @@ -239,17 +366,17 @@ export class ProcessTerminal implements Terminal { } async drainInput(maxMs = 1000, idleMs = 50): Promise { - if (this._kittyProtocolActive) { + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + if (shouldDisableKittyProtocol) { // Disable Kitty keyboard protocol first so any late key releases // do not generate new Kitty escape sequences. process.stdout.write("\x1b[4;0m"); - this._modifyOtherKeysActive = false; - } + this.disableModifyOtherKeys(); const previousHandler = this.inputHandler; this.inputHandler = undefined; @@ -284,16 +411,17 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + // Disable Kitty keyboard protocol if not already done by drainInput() - if (this._kittyProtocolActive) { + if (shouldDisableKittyProtocol) { process.stdout.write("\x1b[4;0m"); - this._modifyOtherKeysActive = false; - } + this.disableModifyOtherKeys(); // Clean up StdinBuffer if (this.stdinBuffer) { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2874ef02..a7054888 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -8,29 +8,54 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.ts"; import type { Terminal } from "./terminal.ts"; +import { + isOsc11BackgroundColorResponse, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; -function extractKittyImageIds(line: string): number[] { +interface KittyImageHeader { + ids: number[]; + rows: number; +} + +function parseKittyImageHeader(line: string): KittyImageHeader | undefined { const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); - if (sequenceStart === -1) return []; + if (sequenceStart === -1) return undefined; const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; const paramsEnd = line.indexOf(";", paramsStart); - if (paramsEnd === -1) return []; + if (paramsEnd === -1) return undefined; + const ids: number[] = []; + let rows = 1; const params = line.slice(paramsStart, paramsEnd); for (const param of params.split(",")) { const [key, value] = param.split("=", 2); - if (key !== "i" || value === undefined) continue; - const id = Number(value); - if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) { - return [id]; + if (value === undefined) continue; + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; + if (key === "i") { + ids.push(numberValue); + } else if (key === "r") { + rows = numberValue; } } - return []; + return { ids, rows }; +} + +function extractKittyImageIds(line: string): number[] { + return parseKittyImageHeader(line)?.ids ?? []; +} + +function extractKittyImageRows(line: string): number { + return parseKittyImageHeader(line)?.rows ?? 1; } /** @@ -64,6 +89,11 @@ export interface Component { type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; +type PendingOsc11BackgroundQuery = { + settled: boolean; + resolve: ((rgb: RgbColor | undefined) => void) | undefined; + timer: NodeJS.Timeout | undefined; +}; /** * Interface for components that can receive focus and display a hardware cursor. @@ -176,6 +206,12 @@ export interface OverlayOptions { nonCapturing?: boolean; } +/** Options for {@link OverlayHandle.unfocus}. */ +export interface OverlayUnfocusOptions { + /** Explicit target to focus after releasing this overlay. */ + target: Component | null; +} + /** * Handle returned by showOverlay for controlling the overlay */ @@ -188,12 +224,32 @@ export interface OverlayHandle { isHidden(): boolean; /** Focus this overlay and bring it to the visual front */ focus(): void; - /** Release focus to the previous target */ - unfocus(): void; + /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ + unfocus(options?: OverlayUnfocusOptions): void; /** Check if this overlay currently has focus */ isFocused(): boolean; } +type OverlayStackEntry = { + component: Component; + options?: OverlayOptions; + preFocus: Component | null; + hidden: boolean; + focusOrder: number; +}; + +type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; +type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; +type BlockedOverlayFocusRestoreState = { + status: "blocked"; + overlay: OverlayStackEntry; + blockedBy: Component; + resume: OverlayBlockedFocusResume; +}; +type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; +type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; +type OverlayFocusRestorePolicy = "clear" | "preserve"; + /** * Container - a component that contains other components */ @@ -259,16 +315,15 @@ export class TUI extends Container { private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves private fullRedrawCount = 0; private stopped = false; + private pendingOsc11BackgroundReplies = 0; + private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; + private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); + private terminalColorSchemeNotificationsEnabled = false; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; - private overlayStack: { - component: Component; - options?: OverlayOptions; - preFocus: Component | null; - hidden: boolean; - focusOrder: number; - }[] = []; + private overlayStack: OverlayStackEntry[] = []; + private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; constructor(terminal: Terminal, showHardwareCursor?: boolean) { super(); @@ -309,17 +364,126 @@ export class TUI extends Container { } setFocus(component: Component | null): void { - // Clear focused flag on old component + this.setFocusInternal({ component, overlayFocusRestore: "clear" }); + } + + private setFocusInternal({ + component, + overlayFocusRestore, + }: { + component: Component | null; + overlayFocusRestore: OverlayFocusRestorePolicy; + }): void { + const previousFocus = this.focusedComponent; + let nextFocus = component; + const previousFocusedOverlay = previousFocus + ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) + : undefined; + const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + const restoreState = this.getVisibleOverlayFocusRestore(); + if (nextFocus && !nextFocusIsOverlay) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else { + this.overlayFocusRestore = { + status: "blocked", + overlay: restoreState.overlay, + blockedBy: nextFocus, + resume: restoreState.resume, + }; + } + } else if ( + previousFocusedOverlay && + restoreState.status !== "inactive" && + restoreState.overlay === previousFocusedOverlay && + !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) + ) { + this.overlayFocusRestore = { + status: "blocked", + overlay: previousFocusedOverlay, + blockedBy: nextFocus, + resume: { status: "restore-overlay" }, + }; + } + } else if (nextFocus === null) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else if (overlayFocusRestore === "clear") { + this.clearOverlayFocusRestore(); + } + } + if (isFocusable(this.focusedComponent)) { this.focusedComponent.focused = false; } - this.focusedComponent = component; + this.focusedComponent = nextFocus; - // Set focused flag on new component - if (isFocusable(component)) { - component.focused = true; + if (isFocusable(nextFocus)) { + nextFocus.focused = true; } + + const focusedOverlay = nextFocus + ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) + : undefined; + if (focusedOverlay) { + this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; + } + } + + private clearOverlayFocusRestore(): void { + this.overlayFocusRestore = { status: "inactive" }; + } + + private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { + if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { + this.clearOverlayFocusRestore(); + } + } + + private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { + if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; + this.clearOverlayFocusRestore(); + return restoreState.resume.target; + } + + private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { + const restoreState = this.overlayFocusRestore; + if (restoreState.status === "inactive") return restoreState; + if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { + return { status: "inactive" }; + } + return restoreState; + } + + private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { + const visited = new Set(); + let current = entry.preFocus; + while (current && !visited.has(current)) { + visited.add(current); + if (current === component) return true; + current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; + } + return false; + } + + private retargetOverlayPreFocus(removed: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + if (overlay !== removed && overlay.preFocus === removed.component) { + overlay.preFocus = removed.preFocus; + } + } + } + + private isComponentMounted(component: Component): boolean { + return this.children.some((child) => this.containsComponent(child, component)); + } + + private containsComponent(root: Component, target: Component): boolean { + if (root === target) return true; + if (!(root instanceof Container)) return false; + return root.children.some((child) => this.containsComponent(child, target)); } /** @@ -327,9 +491,9 @@ export class TUI extends Container { * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - const entry = { + const entry: OverlayStackEntry = { component, - options, + ...(options === undefined ? {} : { options }), preFocus: this.focusedComponent, hidden: false, focusOrder: ++this.focusOrderCounter, @@ -347,6 +511,8 @@ export class TUI extends Container { hide: () => { const index = this.overlayStack.indexOf(entry); if (index !== -1) { + this.clearOverlayFocusRestoreFor(entry); + this.retargetOverlayPreFocus(entry); this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus if (this.focusedComponent === component) { @@ -362,6 +528,7 @@ export class TUI extends Container { entry.hidden = hidden; // Update focus when hiding/showing if (hidden) { + this.clearOverlayFocusRestoreFor(entry); // If this overlay had focus, move focus to next visible or preFocus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); @@ -379,16 +546,39 @@ export class TUI extends Container { isHidden: () => entry.hidden, focus: () => { if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; - if (this.focusedComponent !== component) { - this.setFocus(component); - } entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); this.requestRender(); }, - unfocus: () => { - if (this.focusedComponent !== component) return; - const topVisible = this.getTopmostVisibleOverlay(); - this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus); + unfocus: (unfocusOptions) => { + const isFocused = this.focusedComponent === component; + const restoreState = this.overlayFocusRestore; + const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; + if (!isFocused && !hasPendingRestore) return; + if ( + restoreState.status === "blocked" && + restoreState.overlay === entry && + this.focusedComponent === restoreState.blockedBy + ) { + if (unfocusOptions) { + this.overlayFocusRestore = { + status: "blocked", + overlay: entry, + blockedBy: restoreState.blockedBy, + resume: { status: "focus-target", target: unfocusOptions.target }, + }; + } else { + this.clearOverlayFocusRestore(); + } + this.requestRender(); + return; + } + this.clearOverlayFocusRestoreFor(entry); + if (isFocused || unfocusOptions) { + const topVisible = this.getTopmostVisibleOverlay(); + const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; + this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); + } this.requestRender(); }, isFocused: () => this.focusedComponent === component, @@ -397,8 +587,11 @@ export class TUI extends Container { /** Hide the topmost overlay and restore previous focus. */ hideOverlay(): void { - const overlay = this.overlayStack.pop(); + const overlay = this.overlayStack[this.overlayStack.length - 1]; if (!overlay) return; + this.clearOverlayFocusRestoreFor(overlay); + this.retargetOverlayPreFocus(overlay); + this.overlayStack.pop(); if (this.focusedComponent === overlay.component) { // Find topmost visible overlay, or fall back to preFocus const topVisible = this.getTopmostVisibleOverlay(); @@ -414,7 +607,7 @@ export class TUI extends Container { } /** Check if an overlay entry is currently visible */ - private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { + private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; if (entry.options?.visible) { return entry.options.visible(this.terminal.columns, this.terminal.rows); @@ -422,15 +615,16 @@ export class TUI extends Container { return true; } - /** Find the topmost visible capturing overlay, if any */ - private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { - for (let i = this.overlayStack.length - 1; i >= 0; i--) { - if (this.overlayStack[i].options?.nonCapturing) continue; - if (this.isOverlayVisible(this.overlayStack[i])) { - return this.overlayStack[i]; + /** Find the visual-frontmost visible capturing overlay, if any */ + private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { + let topmost: OverlayStackEntry | undefined; + for (const overlay of this.overlayStack) { + if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; + if (!topmost || overlay.focusOrder > topmost.focusOrder) { + topmost = overlay; } } - return undefined; + return topmost; } override invalidate(): void { @@ -445,6 +639,9 @@ export class TUI extends Container { () => this.requestRender(), ); this.terminal.hideCursor(); + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031h"); + } this.queryCellSize(); this.requestRender(); } @@ -460,6 +657,23 @@ export class TUI extends Container { this.inputListeners.delete(listener); } + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void { + this.terminalColorSchemeListeners.add(listener); + return () => { + this.terminalColorSchemeListeners.delete(listener); + }; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + if (this.terminalColorSchemeNotificationsEnabled === enabled) { + return; + } + this.terminalColorSchemeNotificationsEnabled = enabled; + if (!this.stopped) { + this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l"); + } + } + private queryCellSize(): void { // Only query if terminal supports images (cell size is only used for image rendering) if (!getCapabilities().images) { @@ -476,6 +690,9 @@ export class TUI extends Container { clearTimeout(this.renderTimer); this.renderTimer = undefined; } + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031l"); + } // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content @@ -542,6 +759,13 @@ export class TUI extends Container { } private handleInput(data: string): void { + if (this.consumeOsc11BackgroundResponse(data)) { + return; + } + if (this.consumeTerminalColorSchemeReport(data)) { + return; + } + if (this.inputListeners.size > 0) { let current = data; for (const listener of this.inputListeners) { @@ -579,8 +803,22 @@ export class TUI extends Container { if (topVisible) { this.setFocus(topVisible.component); } else { - // No visible overlays, restore to preFocus - this.setFocus(focusedOverlay.preFocus); + this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); + } + } + + const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); + if (!focusIsOverlay) { + const restoreState = this.getVisibleOverlayFocusRestore(); + if (restoreState.status === "eligible") { + this.setFocus(restoreState.overlay.component); + } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { + if (restoreState.resume.status === "restore-overlay") { + this.setFocus(restoreState.overlay.component); + } else { + this.clearOverlayFocusRestore(); + this.setFocus(restoreState.resume.target); + } } } @@ -596,6 +834,42 @@ export class TUI extends Container { } } + private consumeOsc11BackgroundResponse(data: string): boolean { + if (this.pendingOsc11BackgroundReplies <= 0) { + return false; + } + + if (!isOsc11BackgroundColorResponse(data)) { + return false; + } + + const rgb = parseOsc11BackgroundColor(data); + this.pendingOsc11BackgroundReplies -= 1; + const query = this.pendingOsc11BackgroundQueries.shift(); + if (query && !query.settled) { + query.settled = true; + if (query.timer) { + clearTimeout(query.timer); + query.timer = undefined; + } + query.resolve?.(rgb); + query.resolve = undefined; + } + return true; + } + + private consumeTerminalColorSchemeReport(data: string): boolean { + const scheme = parseTerminalColorSchemeReport(data); + if (!scheme) { + return false; + } + + for (const listener of this.terminalColorSchemeListeners) { + listener(scheme); + } + return true; + } + private consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); @@ -847,14 +1121,41 @@ export class TUI extends Container { return buffer; } - private expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number { - let expandedLastChanged = lastChanged; - for (let i = firstChanged; i < this.previousLines.length; i++) { - if (extractKittyImageIds(this.previousLines[i]).length > 0) { - expandedLastChanged = Math.max(expandedLastChanged, i); - } + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; } - return expandedLastChanged; + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[]): void => { + for (let i = 0; i < lines.length; i++) { + if (extractKittyImageIds(lines[i]).length === 0) continue; + const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; + if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { + expandedFirstChanged = Math.min(expandedFirstChanged, i); + expandedLastChanged = Math.max(expandedLastChanged, blockEnd); + } + } + }; + + expandForLines(this.previousLines); + expandForLines(newLines); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; } private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { @@ -989,7 +1290,20 @@ export class TUI extends Container { } for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; + if (imageReservedRows > 1 && imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + buffer += line; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); @@ -1073,7 +1387,9 @@ export class TUI extends Container { lastChanged = newLines.length - 1; } if (firstChanged !== -1) { - lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged); + const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + firstChanged = expandedRange.firstChanged; + lastChanged = expandedRange.lastChanged; } const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; @@ -1108,15 +1424,17 @@ export class TUI extends Container { fullRender(true); return; } - if (extraLines > 0) { - buffer += "\x1b[1B"; + const clearStartOffset = newLines.length === 0 ? 0 : 1; + if (extraLines > 0 && clearStartOffset > 0) { + buffer += `\x1b[${clearStartOffset}B`; } for (let i = 0; i < extraLines; i++) { buffer += "\r\x1b[2K"; if (i < extraLines - 1) buffer += "\x1b[1B"; } - if (extraLines > 0) { - buffer += `\x1b[${extraLines}A`; + const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); + if (moveBack > 0) { + buffer += `\x1b[${moveBack}A`; } buffer += "\x1b[?2026l"; this.terminal.write(buffer); @@ -1174,9 +1492,31 @@ export class TUI extends Container { const renderEnd = Math.min(lastChanged, newLines.length - 1); for (let i = firstChanged; i <= renderEnd; i++) { if (i > firstChanged) buffer += "\r\n"; - buffer += "\x1b[2K"; // Clear current line const line = newLines[i]; const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; + if (imageReservedRows > 1) { + const imageStartScreenRow = i - viewportTop; + if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { + logRedraw( + `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, + ); + fullRender(true); + return; + } + + buffer += "\x1b[2K"; + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + + buffer += "\x1b[2K"; // Clear current line if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); @@ -1316,4 +1656,59 @@ export class TUI extends Container { this.terminal.hideCursor(); } } + + /** + * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). + * @param timeoutMs Query timeout in milliseconds. + * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + */ + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + const query: PendingOsc11BackgroundQuery = { + settled: false, + resolve, + timer: undefined, + }; + + query.timer = setTimeout(() => { + if (query.settled) { + return; + } + query.settled = true; + query.timer = undefined; + query.resolve?.(undefined); + query.resolve = undefined; + }, timeoutMs); + this.pendingOsc11BackgroundQueries.push(query); + this.pendingOsc11BackgroundReplies += 1; + this.terminal.write("\x1b]11;?\x07"); + }); + } + + /** + * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). + * Terminals that support the color palette notification protocol reply with + * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. + */ + queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + let unsubscribe: () => void = () => {}; + const settle = (scheme: TerminalColorScheme | undefined) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + unsubscribe(); + resolve(scheme); + }; + + unsubscribe = this.onTerminalColorSchemeChange(settle); + timer = setTimeout(() => settle(undefined), timeoutMs); + this.terminal.write("\x1b[?996n"); + }); + } } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index a6bcc8a5..b074a3a2 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,13 +1,21 @@ import { eastAsianWidth } from "get-east-asian-width"; -// Grapheme segmenter (shared instance) -const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +// segmenters (shared instance) +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" }); /** * Get the shared grapheme segmenter instance. */ -export function getSegmenter(): Intl.Segmenter { - return segmenter; +export function getGraphemeSegmenter(): Intl.Segmenter { + return graphemeSegmenter; +} + +/** + * Get the shared word segmenter instance. + */ +export function getWordSegmenter(): Intl.Segmenter { + return wordSegmenter; } /** @@ -37,6 +45,9 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); +export const cjkBreakRegex = + /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + function isPrintableAscii(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); @@ -62,7 +73,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string if (!hasAnsi && !hasTabs) { let result = ""; let width = 0; - for (const { segment } of segmenter.segment(text)) { + for (const { segment } of graphemeSegmenter.segment(text)) { const w = graphemeWidth(segment); if (width + w > maxWidth) { break; @@ -109,7 +120,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string end++; } - for (const { segment } of segmenter.segment(text.slice(i, end))) { + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { const w = graphemeWidth(segment); if (width + w > maxWidth) { return { text: result, width }; @@ -154,6 +165,10 @@ function finalizeTruncatedResult( * check to avoid running the RGI_Emoji regex unnecessarily. */ function graphemeWidth(segment: string): number { + if (segment === "\t") { + return 3; + } + // Zero-width clusters if (zeroWidthRegex.test(segment)) { return 0; @@ -239,7 +254,7 @@ export function visibleWidth(str: string): number { // Calculate width let width = 0; - for (const { segment } of segmenter.segment(clean)) { + for (const { segment } of graphemeSegmenter.segment(clean)) { width += graphemeWidth(segment); } @@ -593,9 +608,18 @@ function splitIntoTokensWithAnsi(text: string): string[] { const tokens: string[] = []; let current = ""; let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content - let inWhitespace = false; + let currentKind: "space" | "word" | null = null; let i = 0; + const flushCurrent = (): void => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + while (i < text.length) { const ansiResult = extractAnsiCode(text, i); if (ansiResult) { @@ -605,29 +629,48 @@ function splitIntoTokensWithAnsi(text: string): string[] { continue; } - const char = text[i]; - const charIsSpace = char === " "; - - if (charIsSpace !== inWhitespace && current) { - // Switching between whitespace and non-whitespace, push current token - tokens.push(current); - current = ""; + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; } - // Attach any pending ANSI codes to this visible character - if (pendingAnsi) { - current += pendingAnsi; - pendingAnsi = ""; + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + + currentKind = segmentKind; + current += segment; } - inWhitespace = charIsSpace; - current += char; - i++; + i = end; } // Handle any remaining pending ANSI codes (attach to last token) if (pendingAnsi) { - current += pendingAnsi; + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } } if (current) { @@ -662,7 +705,10 @@ export function wrapTextWithAnsi(text: string, width: number): string[] { for (const inputLine of inputLines) { // Prepend active ANSI codes from previous lines (except for first line) const prefix = result.length > 0 ? tracker.getActiveCodes() : ""; - result.push(...wrapSingleLine(prefix + inputLine, width)); + const wrappedLines = wrapSingleLine(prefix + inputLine, width); + for (const wrappedLine of wrappedLines) { + result.push(wrappedLine); + } // Update tracker with codes from this line for next iteration updateTrackerFromText(inputLine, tracker); } @@ -706,7 +752,9 @@ function wrapSingleLine(line: string, width: number): string[] { // Break long token - breakLongWord handles its own resets const broken = breakLongWord(token, width, tracker); - wrapped.push(...broken.slice(0, -1)); + for (let i = 0; i < broken.length - 1; i++) { + wrapped.push(broken[i]!); + } currentLine = broken[broken.length - 1]; currentVisibleLength = visibleWidth(currentLine); continue; @@ -749,7 +797,7 @@ function wrapSingleLine(line: string, width: number): string[] { return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; } -const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; +export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; /** * Check if a character is whitespace. @@ -790,7 +838,7 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s } // Segment this non-ANSI portion into graphemes const textPortion = word.slice(i, end); - for (const seg of segmenter.segment(textPortion)) { + for (const seg of graphemeSegmenter.segment(textPortion)) { segments.push({ type: "grapheme", value: seg.segment }); } i = end; @@ -912,7 +960,7 @@ export function truncateToWidth( const hasTabs = text.includes("\t"); if (!hasAnsi && !hasTabs) { - for (const { segment } of segmenter.segment(text)) { + for (const { segment } of graphemeSegmenter.segment(text)) { const width = graphemeWidth(segment); if (keepContiguousPrefix && keptWidth + width <= targetWidth) { result += segment; @@ -967,7 +1015,7 @@ export function truncateToWidth( end++; } - for (const { segment } of segmenter.segment(text.slice(i, end))) { + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { const width = graphemeWidth(segment); if (keepContiguousPrefix && keptWidth + width <= targetWidth) { if (pendingAnsi) { @@ -1037,7 +1085,7 @@ export function sliceWithWidth( let textEnd = i; while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); const inRange = currentCol >= startCol && currentCol < endCol; const fits = !strict || currentCol + w <= endCol; @@ -1105,10 +1153,10 @@ export function extractSegments( let textEnd = i; while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); - if (currentCol < beforeEnd) { + if (currentCol < beforeEnd && currentCol + w <= beforeEnd) { if (pendingAnsiBefore) { before += pendingAnsiBefore; pendingAnsiBefore = ""; diff --git a/packages/tui/src/word-navigation.ts b/packages/tui/src/word-navigation.ts new file mode 100644 index 00000000..7c7eced2 --- /dev/null +++ b/packages/tui/src/word-navigation.ts @@ -0,0 +1,117 @@ +import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts"; + +const wordSegmenter = getWordSegmenter(); + +/** + * Options for word navigation functions. + * When omitted, uses the default Intl.Segmenter word segmentation. + */ +export interface WordNavigationOptions { + /** Custom segmenter returning word segments for the given text. */ + segment?: (text: string) => Iterable; + /** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */ + isAtomicSegment?: (segment: string) => boolean; +} + +/** + * Find the cursor position after moving one word backward from `cursor` in `text`. + * Skips trailing whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor <= 0) return 0; + + const textBeforeCursor = text.slice(0, cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)]; + let newCursor = cursor; + + // Skip trailing whitespace + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + + if (segments.length === 0) return newCursor; + + const last = segments[segments.length - 1]!; + + if (isAtomic?.(last.segment)) { + // Skip one atomic segment. + newCursor -= last.segment.length; + } else if (last.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + const segment = last.segment; + const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; + if (matches.length <= 0) { + newCursor -= segment.length; + } else { + const lastMatch = matches[matches.length - 1]!; + newCursor -= segment.length - (lastMatch.index + lastMatch[0].length); + } + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + !segments[segments.length - 1]?.isWordLike && + !isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + } + + return newCursor; +} + +/** + * Find the cursor position after moving one word forward from `cursor` in `text`. + * Skips leading whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor >= text.length) return text.length; + + const textAfterCursor = text.slice(cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); + let newCursor = cursor; + + // Skip leading whitespace + while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + + if (next.done) return newCursor; + + if (isAtomic?.(next.value.segment)) { + // Skip one atomic segment. + newCursor += next.value.segment.length; + } else if (next.value.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + !next.done && + !isAtomic?.(next.value.segment) && + !next.value.isWordLike && + !isWhitespaceChar(next.value.segment) + ) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + } + + return newCursor; +} diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e7e74ec3..0f33370e 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -79,16 +79,24 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("returns to empty editor on Down arrow after browsing history", () => { + it("jumps to start before entering history from a non-empty draft", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[A"); // Up - shows "prompt" + editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[A"); // Up at start - shows "prompt" assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - clears editor - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); it("navigates forward through history with Down arrow", () => { @@ -97,8 +105,10 @@ describe("Editor component", () => { editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); + editor.setText("draft"); // Go to oldest + editor.handleInput("\x1b[A"); // start of draft editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // second editor.handleInput("\x1b[A"); // first @@ -110,8 +120,8 @@ describe("Editor component", () => { editor.handleInput("\x1b[B"); // third assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // empty - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); }); it("exits history mode when typing a character", () => { @@ -122,7 +132,7 @@ describe("Editor component", () => { editor.handleInput("\x1b[A"); // Up - shows "old prompt" editor.handleInput("x"); // Type a character - exits history mode - assert.strictEqual(editor.getText(), "old promptx"); + assert.strictEqual(editor.getText(), "xold prompt"); }); it("exits history mode on setText", () => { @@ -222,61 +232,55 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "prompt 5"); }); - it("allows cursor movement within multi-line history entry with Down", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - - editor.addToHistory("line1\nline2\nline3"); - - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end of line3 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Down should exit history since cursor is on last line - editor.handleInput("\x1b[B"); // Down - assert.strictEqual(editor.getText(), ""); // Exited to empty - }); - - it("allows cursor movement within multi-line history entry with Up", () => { + it("places cursor at start after browsing history upward", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("older entry"); editor.addToHistory("line1\nline2\nline3"); - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows multi-line, cursor at end of line3 + editor.handleInput("\x1b[A"); // Up - shows multi-line entry at start + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Up should move cursor within the entry (not on first line yet) - editor.handleInput("\x1b[A"); // Up - cursor moves to line2 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - editor.handleInput("\x1b[A"); // Up - cursor moves to line1 (now on first visual line) - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - // Now Up should navigate to older history entry - editor.handleInput("\x1b[A"); // Up - navigate to older + editor.handleInput("\x1b[A"); // Up again - immediately navigates to older entry assert.strictEqual(editor.getText(), "older entry"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); - it("navigates from multi-line entry back to newer via Down after cursor movement", () => { + it("places cursor at end after browsing history downward", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + editor.addToHistory("newer entry"); + + editor.handleInput("\x1b[A"); // newer entry + editor.handleInput("\x1b[A"); // multi-line entry + editor.handleInput("\x1b[A"); // older entry + + editor.handleInput("\x1b[B"); // Down - shows multi-line entry at end + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + editor.handleInput("\x1b[B"); // Down again - immediately navigates to newer entry + assert.strictEqual(editor.getText(), "newer entry"); + }); + + it("allows opposite-direction cursor movement within multi-line history entry", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); - // Browse to entry and move cursor up - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end - editor.handleInput("\x1b[A"); // Up - cursor to line2 - editor.handleInput("\x1b[A"); // Up - cursor to line1 + editor.handleInput("\x1b[A"); // Up - shows entry at start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Now Down should move cursor down within the entry - editor.handleInput("\x1b[B"); // Down - cursor to line2 + editor.handleInput("\x1b[B"); // Down - cursor moves to line2 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); - editor.handleInput("\x1b[B"); // Down - cursor to line3 + editor.handleInput("\x1b[A"); // Up - cursor moves back to line1 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Now on last line, Down should exit history - editor.handleInput("\x1b[B"); // Down - exit to empty - assert.strictEqual(editor.getText(), ""); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); }); @@ -532,6 +536,15 @@ describe("Editor component", () => { editor.handleInput("\x17"); assert.strictEqual(editor.getText(), "foo bar"); + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo."); + + editor.setText("foo:bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo:"); + // Delete across multiple lines editor.setText("line one\nline two"); editor.handleInput("\x17"); @@ -590,6 +603,99 @@ describe("Editor component", () => { editor.handleInput("\x01"); // Ctrl+A to go to start editor.handleInput("\x1b[1;5C"); // Ctrl+Right assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo' + + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar baz"); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over baz + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over foo + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + }); + + it("stops at fullwidth Chinese punctuation (issue #4972)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // 你好,世界 = 你好(0-2) ,(2-3) 世界(3-5) + editor.setText("你好,世界"); + // Cursor at end (col 5) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Move right over 你好 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move right over , + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move right over 世界 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // end + }); + + it("handles mixed CJK and ASCII word movement", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "hello你好,world世界" = hello(0-5) 你好(5-7) ,(7-8) world(8-13) 世界(13-15) + editor.setText("hello你好,world世界"); + // Cursor at end (col 15) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + // Move left over world + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + // Move left over hello + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Forward from start + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); // end }); }); @@ -2154,6 +2260,65 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("re-queries the autocomplete picker when the cursor moves back into the command name", async () => { + // Regression for earendil-works/pi#5496: arrowing left out of a slash + // command's argument region must re-query the picker, not leave the + // stale argument list showing. Before the fix, moveCursor() never + // called updateAutocomplete(), so `/cmd ` (argument menu) + Left kept + // displaying the arguments against a `/cmd` prefix — and a Tab there + // would concatenate the stale suggestion onto the partial command name. + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const before = (lines[0] || "").slice(0, cursorCol); + if (!before.startsWith("/")) return null; + // Past the command name (a space before the cursor): offer arguments. + if (before.includes(" ")) { + return { + items: [ + { value: "repo", label: "repo" }, + { value: "message", label: "message" }, + { value: "help", label: "help" }, + ], + prefix: before.slice(before.indexOf(" ") + 1), + }; + } + // Inside the command name: offer the command name only. + return { items: [{ value: "cmd", label: "cmd" }], prefix: before }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type `/cmd ` so the picker ends up showing the argument list. + for (const ch of "/cmd ") { + editor.handleInput(ch); + await flushAutocomplete(); + } + assert.strictEqual(editor.getText(), "/cmd "); + assert.strictEqual(editor.isShowingAutocomplete(), true); + const atArg = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(atArg.includes("repo"), "argument menu should be visible at `/cmd `"); + + // Arrow Left back into the command name (`/cmd`). + editor.handleInput("\x1b[D"); + await flushAutocomplete(); + + // The picker must have re-queried: the stale argument items are gone + // (replaced by the command-name suggestion, or the picker closed). + const afterMove = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(!afterMove.includes("repo"), "stale argument menu must not survive the cursor move"); + assert.ok(!afterMove.includes("message"), "stale argument menu must not survive the cursor move"); + }); + it("debounces # autocomplete while typing", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let suggestionCalls = 0; @@ -2187,6 +2352,58 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("debounces custom triggerCharacters autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const prefix = (lines[0] || "").slice(0, cursorCol); + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + editor.handleInput("k"); + + assert.strictEqual(suggestionCalls, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("resets custom triggerCharacters when provider changes", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }), + applyCompletion, + }); + editor.setAutocompleteProvider({ + getSuggestions: async () => { + suggestionCalls += 1; + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + it("aborts active @ autocomplete when typing continues", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let aborts = 0; diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts index 7415e963..af4662af 100644 --- a/packages/tui/test/fuzzy.test.ts +++ b/packages/tui/test/fuzzy.test.ts @@ -102,4 +102,11 @@ describe("fuzzyFilter", () => { assert.ok(result.map((r) => r.name).includes("foo")); assert.ok(result.map((r) => r.name).includes("foobar")); }); + + it("matches slash-separated provider/model queries against reordered text", () => { + const item = { id: "gpt-5.5", provider: "openai-codex" }; + const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`); + + assert.deepStrictEqual(result, [item]); + }); }); diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index e980fb05..5b83ec71 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -100,6 +100,40 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "bazfoo bar "); }); + it("Ctrl+W preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo."); + + input.setValue("foo:bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo:"); + }); + + it("Ctrl+W handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好世界。你好,"); + input.handleInput("\x17"); // Ctrl+W - deletes "," + assert.strictEqual(input.getValue(), "你好世界。你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), "你好世界。"); + input.handleInput("\x17"); // Ctrl+W - deletes "。" + assert.strictEqual(input.getValue(), "你好世界"); + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), ""); + }); + it("Ctrl+U saves deleted text to kill ring", () => { const input = new Input(); @@ -313,6 +347,39 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "hello world test"); }); + it("Alt+D preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar baz"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "foo" + assert.strictEqual(input.getValue(), ".bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "." + assert.strictEqual(input.getValue(), "bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "bar" + assert.strictEqual(input.getValue(), " baz"); + }); + + it("Alt+D handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), "世界。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), "。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "。" + assert.strictEqual(input.getValue(), "你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), ",世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "," + assert.strictEqual(input.getValue(), "世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), ""); + }); + it("handles yank in middle of text", () => { const input = new Input(); diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index 650b98cb..dce060d4 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -2,6 +2,7 @@ import { matchesKey } from "../src/keys.ts"; import { ProcessTerminal } from "../src/terminal.ts"; import { type Component, TUI } from "../src/tui.ts"; +import { truncateToWidth } from "../src/utils.ts"; /** * Simple key code logger component @@ -10,9 +11,11 @@ class KeyLogger implements Component { private log: string[] = []; private maxLines = 20; private tui: TUI; + private terminal: ProcessTerminal; - constructor(tui: TUI) { + constructor(tui: TUI, terminal: ProcessTerminal) { this.tui = tui; + this.terminal = terminal; } handleInput(data: string): void { @@ -52,18 +55,29 @@ class KeyLogger implements Component { // No cached state to invalidate currently } + private protocolName(): string { + if (this.terminal.kittyProtocolActive) return "kitty"; + if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys"; + return "legacy"; + } + + private fit(line: string, width: number): string { + return truncateToWidth(line, width).padEnd(width); + } + render(width: number): string[] { const lines: string[] = []; // Title lines.push("=".repeat(width)); - lines.push("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(width)); + lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width)); + lines.push(this.fit(`Protocol: ${this.protocolName()}`, width)); lines.push("=".repeat(width)); lines.push(""); // Log entries for (const entry of this.log) { - lines.push(entry.padEnd(width)); + lines.push(this.fit(entry, width)); } // Fill remaining space @@ -74,12 +88,12 @@ class KeyLogger implements Component { // Footer lines.push("=".repeat(width)); - lines.push("Test these:".padEnd(width)); - lines.push(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(width)); - lines.push(" - Alt/Option + Enter".padEnd(width)); - lines.push(" - Option/Alt + Backspace".padEnd(width)); - lines.push(" - Cmd/Ctrl + Backspace".padEnd(width)); - lines.push(" - Regular Backspace".padEnd(width)); + lines.push(this.fit("Test these:", width)); + lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width)); + lines.push(this.fit(" - Alt/Option + Enter", width)); + lines.push(this.fit(" - Option/Alt + Backspace", width)); + lines.push(this.fit(" - Cmd/Ctrl + Backspace", width)); + lines.push(this.fit(" - Regular Backspace", width)); lines.push("=".repeat(width)); return lines; @@ -89,7 +103,7 @@ class KeyLogger implements Component { // Set up TUI const terminal = new ProcessTerminal(); const tui = new TUI(terminal); -const logger = new KeyLogger(tui); +const logger = new KeyLogger(tui, terminal); tui.addChild(logger); tui.setFocus(logger); @@ -103,3 +117,7 @@ process.on("SIGINT", () => { // Start the TUI tui.start(); + +// Protocol negotiation completes asynchronously after the first render. +// Refresh briefly/continuously so the displayed protocol state is not stale. +setInterval(() => tui.requestRender(), 100); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 584c583c..47bc0a81 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -104,6 +104,42 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second"))); }); + it("should normalize ordered list markers by default", () => { + const markdown = new Markdown("1. alpha\n1. beta\n1. gamma", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. alpha", "2. beta", "3. gamma"]); + }); + + it("should preserve source list markers when configured", () => { + const markdown = new Markdown( + " 4. forth\n 3. third\n\n10) ten\n7) seven\n\n+ plus\n* star\n- minus\n+", + 0, + 0, + defaultMarkdownTheme, + undefined, + { + preserveOrderedListMarkers: true, + }, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "4. forth", + "3. third", + "", + "10) ten", + "7) seven", + "", + "+ plus", + "* star", + "- minus", + "+", + ]); + }); + it("should render mixed ordered and unordered nested lists", () => { const markdown = new Markdown( `1. Ordered item @@ -124,6 +160,37 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); }); + it("should render blank lines between loose list items", () => { + const markdown = new Markdown( + `1. Lorem ipsum dolor sit amet. + + Ut enim ad minim veniam. + +2. Duis aute irure dolor. + + Excepteur sint occaecat cupidatat. + +3. Beep boop`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "1. Lorem ipsum dolor sit amet.", + "", + " Ut enim ad minim veniam.", + "", + "2. Duis aute irure dolor.", + "", + " Excepteur sint occaecat cupidatat.", + "", + "3. Beep boop", + ]); + }); + it("should render task list markers", () => { const markdown = new Markdown("- [ ] beep\n- [x] boop", 0, 0, defaultMarkdownTheme); @@ -1309,4 +1376,47 @@ bar`, ); }); }); + + describe("Streaming code fences", () => { + it("stabilizes partial closing fence rendering", () => { + const cases = [ + { + input: "```ts\nconst x = 1;\n``", + expected: ["```ts", " const x = 1;", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```", + expected: ["```md", " not a closing fence:", " ``", "```"], + }, + { + input: "```ts\n``", + expected: ["```ts", "", "```"], + }, + { + input: "````\n```", + expected: ["```", "", "```"], + }, + { + input: "~~~~~\n~~~~", + expected: ["```", "", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```\n\nafter", + expected: ["```md", " not a closing fence:", " ``", "```", "", "after"], + }, + ]; + + for (const { input, expected } of cases) { + const markdown = new Markdown(input, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, expected); + } + + const partial = new Markdown("```ts\nconst x = 1;\n``", 0, 0, defaultMarkdownTheme); + const complete = new Markdown("```ts\nconst x = 1;\n```", 0, 0, defaultMarkdownTheme); + + assert.strictEqual(partial.render(80).length, complete.render(80).length); + }); + }); }); diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index cb91e5e4..64ce6203 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Component, Focusable } from "../src/tui.ts"; -import { TUI } from "../src/tui.ts"; +import { Container, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -222,6 +222,35 @@ describe("TUI overlay non-capturing", () => { } }); + it("removed focused child overlay does not become parent overlay fallback", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const child = new FocusableOverlay(["CHILD"]); + const parent = new FocusableOverlay(["PARENT"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const childHandle = tui.showOverlay(child, { nonCapturing: true }); + childHandle.focus(); + const parentHandle = tui.showOverlay(parent); + assert.strictEqual(parent.focused, true); + + childHandle.hide(); + parentHandle.hide(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(child.inputs, []); + assert.deepStrictEqual(parent.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -234,16 +263,18 @@ describe("TUI overlay non-capturing", () => { try { // Simulate showExtensionCustom: factory creates timer synchronously, // then .then() pushes controller as a microtask - let timerHandle: ReturnType; - let doneFn: () => void; + let timerHandle: ReturnType | null = null; + let doneFn: () => void = () => { + throw new Error("doneFn was not initialized"); + }; const overlayPromise = new Promise((resolve) => { doneFn = () => { + if (!timerHandle) throw new Error("timerHandle was not initialized"); timerHandle.hide(); tui.hideOverlay(); resolve(); }; - // Factory runs synchronously: creates timer sub-overlay timerHandle = tui.showOverlay(timer, { nonCapturing: true }); // .then() runs as microtask — same as showExtensionCustom Promise.resolve(controller).then((c) => { @@ -251,15 +282,14 @@ describe("TUI overlay non-capturing", () => { }); }); - // Wait for .then() microtask and renders to settle - await new Promise((r) => setTimeout(r, 50)); + await Promise.resolve(); await renderAndFlush(tui, terminal); assert.strictEqual(controller.focused, true); assert.strictEqual(editor.focused, false); // Simulate Esc: cleanup + close (from inside handleInput) - doneFn!(); + doneFn(); // Now await the promise (simulating showExtensionCustom resolving) await overlayPromise; await renderAndFlush(tui, terminal); @@ -305,6 +335,473 @@ describe("TUI overlay non-capturing", () => { } }); + it("active base focus replacement receives close input before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + } finally { + tui.stop(); + } + }); + + it("active replacement still receives input when it is another overlay preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const passive = new FocusableOverlay(["PASSIVE"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.setFocus(replacement); + tui.showOverlay(passive, { nonCapturing: true }); + tui.setFocus(editor); + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("1"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["1", "\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("blocked replacement can move focus internally before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const firstReplacement = new FocusableOverlay(["FIRST"]); + const secondReplacement = new FocusableOverlay(["SECOND"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(firstReplacement); + }; + firstReplacement.handleInput = (data: string) => { + firstReplacement.inputs.push(data); + if (data === "n") tui.setFocus(secondReplacement); + }; + secondReplacement.handleInput = (data: string) => { + secondReplacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(firstReplacement); + base.addChild(secondReplacement); + tui.addChild(base); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("n"); + await renderAndFlush(tui, terminal); + terminal.sendInput("2"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(firstReplacement.inputs, ["n"]); + assert.deepStrictEqual(secondReplacement.inputs, ["2", "\r"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const palette = new FocusableOverlay(["PALETTE"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(palette); + base.addChild(replacement); + tui.addChild(base); + tui.setFocus(palette); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("unfocus target releases a blocked overlay while replacement remains focused", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const fallback = new FocusableOverlay(["FALLBACK"]); + const target = new FocusableOverlay(["TARGET"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(fallback); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + const overlayHandle = tui.showOverlay(overlay); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + overlayHandle.unfocus({ target }); + } + }; + + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(fallback.inputs, []); + assert.deepStrictEqual(target.inputs, ["x"]); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + tui.setFocus(replacement); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const controller = new FocusableOverlay(["CONTROLLER"]); + const subOverlay = new FocusableOverlay(["SUB"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(controller); + const subHandle = tui.showOverlay(subOverlay, { nonCapturing: true }); + subHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(subOverlay.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("passive non-capturing overlay does not regain input after base focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const passive = new FocusableOverlay(["PASSIVE"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(passive, { nonCapturing: true }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(passive.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("unfocus() prevents visible overlay from regaining input", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("setFocus(null) explicitly clears visible overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + tui.setFocus(null); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("blocked replacement setFocus(null) resumes the visible overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(null); + }; + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + tui.setFocus(editor); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(overlay); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput restores the focus-order top overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const lower = new FocusableOverlay(["LOWER"]); + const upper = new FocusableOverlay(["UPPER"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const lowerHandle = tui.showOverlay(lower); + tui.showOverlay(upper); + lowerHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(lower.inputs, ["x"]); + assert.deepStrictEqual(upper.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -481,6 +978,95 @@ describe("TUI overlay non-capturing", () => { tui.stop(); } }); + + it("explicit unfocus target supports cycling between three overlays and editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + const cHandle = tui.showOverlay(c); + + aHandle.focus(); + terminal.sendInput("a"); + await renderAndFlush(tui, terminal); + bHandle.focus(); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + cHandle.focus(); + terminal.sendInput("c"); + await renderAndFlush(tui, terminal); + cHandle.unfocus({ target: editor }); + terminal.sendInput("e"); + await renderAndFlush(tui, terminal); + aHandle.focus(); + terminal.sendInput("A"); + await renderAndFlush(tui, terminal); + aHandle.unfocus({ target: editor }); + terminal.sendInput("E"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(a.inputs, ["a", "A"]); + assert.deepStrictEqual(b.inputs, ["b"]); + assert.deepStrictEqual(c.inputs, ["c"]); + assert.deepStrictEqual(editor.inputs, ["e", "E"]); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicit null unfocus target clears focus without restoring overlays", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus({ target: null }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + tui.showOverlay(c); + aHandle.focus(); + bHandle.focus(); + bHandle.setHidden(true); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(a.inputs, ["x"]); + assert.deepStrictEqual(c.inputs, []); + assert.strictEqual(a.focused, true); + } finally { + tui.stop(); + } + }); }); describe("rendering order", () => { diff --git a/packages/tui/test/regression-overlay-cjk-boundary.test.ts b/packages/tui/test/regression-overlay-cjk-boundary.test.ts new file mode 100644 index 00000000..2ae1f5ed --- /dev/null +++ b/packages/tui/test/regression-overlay-cjk-boundary.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { TUI } from "../src/tui.ts"; +import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +type TuiComposite = { + compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string; +}; + +function compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; + return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); +} + +describe("overlay CJK boundary regression", () => { + it("excludes a wide grapheme from before when overlay starts inside it", () => { + const segments = extractSegments("abcd让EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcd"); + assert.strictEqual(segments.beforeWidth, 4); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + assert.strictEqual(segments.after, "H"); + assert.strictEqual(segments.afterWidth, 1); + }); + + it("keeps ASCII before-segment behavior at the same boundary", () => { + const segments = extractSegments("abcdG EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcdG"); + assert.strictEqual(segments.beforeWidth, 5); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + }); + + it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const prefix = sliceByColumn(out, 0, 5, true); + const overlay = sliceByColumn(out, 5, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(prefix), 5); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); + + it("composites an overlay when it starts at a wide grapheme boundary", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const overlay = sliceByColumn(out, 4, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); +}); diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts new file mode 100644 index 00000000..427a77db --- /dev/null +++ b/packages/tui/test/tab-width.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts"; + +describe("tab width accounting", () => { + it("keeps slice helper widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const slice = sliceWithWidth(text, 0, 10, true); + + assert.strictEqual(slice.text, "out 192M"); + assert.strictEqual(slice.width, 8); + assert.strictEqual(visibleWidth(slice.text), slice.width); + }); + + it("keeps overlay segment widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const segments = extractSegments(text, 10, 13, 10, true); + + assert.strictEqual(segments.before, "out 192M"); + assert.strictEqual(segments.beforeWidth, 8); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + + const tabFits = extractSegments(text, 11, 13, 10, true); + assert.strictEqual(tabFits.before, "out 192M\t"); + assert.strictEqual(tabFits.beforeWidth, 11); + assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth); + }); +}); diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts new file mode 100644 index 00000000..d777e061 --- /dev/null +++ b/packages/tui/test/terminal-colors.test.ts @@ -0,0 +1,249 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { + type Component, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type Terminal, + TUI, +} from "../src/index.ts"; + +class TestTerminal implements Terminal { + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private readonly columnCount: number; + private readonly rowCount: number; + readonly writes: string[] = []; + + constructor(columnCount = 80, rowCount = 24) { + this.columnCount = columnCount; + this.rowCount = rowCount; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + } + + stop(): void { + this.inputHandler = undefined; + this.resizeHandler = undefined; + } + + async drainInput(_maxMs?: number, _idleMs?: number): Promise {} + + write(data: string): void { + this.writes.push(data); + } + + get columns(): number { + return this.columnCount; + } + + get rows(): number { + return this.rowCount; + } + + get kittyProtocolActive(): boolean { + return false; + } + + moveBy(_lines: number): void {} + + hideCursor(): void {} + + showCursor(): void {} + + clearLine(): void {} + + clearFromCursor(): void {} + + clearScreen(): void {} + + setTitle(_title: string): void {} + + setProgress(_active: boolean): void {} + + sendInput(data: string): void { + this.inputHandler?.(data); + } + + sendResize(): void { + this.resizeHandler?.(); + } +} + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(_width: number): string[] { + return []; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("parseOsc11BackgroundColor", () => { + it("parses 16-bit OSC 11 rgb responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), { + r: 0, + g: 128, + b: 255, + }); + }); + + it("parses OSC 11 hex responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 }); + }); + + it("rejects non-strict OSC 11 responses", () => { + assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined); + }); +}); + +describe("parseTerminalColorSchemeReport", () => { + it("parses color scheme reports", () => { + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined); + }); +}); + +describe("TUI.queryTerminalBackgroundColor", () => { + it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + assert.ok(terminal.writes.includes("\x1b]11;?\x07")); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;#000000\x07"); + + assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 }); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;not-a-color\x07"); + + assert.strictEqual(await query, undefined); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + let settled = false; + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => { + settled = true; + return rgb; + }); + + terminal.sendInput("x"); + await Promise.resolve(); + + assert.strictEqual(settled, false); + assert.deepStrictEqual(listenerInputs, ["x"]); + assert.deepStrictEqual(component.inputs, ["x"]); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("keeps consuming a late OSC 11 reply after timeout", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 }); + await wait(5); + + assert.strictEqual(await query, undefined); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); +}); diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 70fbf9eb..cc7e01e5 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -21,6 +21,7 @@ import { const ENV_KEYS = [ "TERM", "TERM_PROGRAM", + "TERMINAL_EMULATOR", "COLORTERM", "TMUX", "KITTY_WINDOW_ID", @@ -29,6 +30,8 @@ const ENV_KEYS = [ "ITERM_SESSION_ID", "WT_SESSION", "CMUX_WORKSPACE_ID", + "WARP_SESSION_ID", + "WARP_TERMINAL_SESSION_UUID", ] as const; function withEnv(overrides: Record, fn: () => void): void { @@ -206,19 +209,30 @@ describe("detectCapabilities", () => { }); }); - it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => { + it("enables hyperlinks under tmux when the client forwards them", () => { withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("disables hyperlinks under tmux when the client does not forward them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); }); }); - it("forces hyperlinks: false when TERM starts with 'tmux'", () => { + it("checks tmux capability when TERM starts with 'tmux'", () => { withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => { - const caps = detectCapabilities(); - assert.strictEqual(caps.hyperlinks, false); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); assert.strictEqual(caps.images, null); + + const caps2 = detectCapabilities(() => false); + assert.strictEqual(caps2.hyperlinks, false); }); }); @@ -259,6 +273,48 @@ describe("detectCapabilities", () => { }); }); + it("enables images and hyperlinks for Warp via TERM_PROGRAM", () => { + withEnv({ TERM_PROGRAM: "WarpTerminal" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_SESSION_ID", () => { + withEnv({ WARP_SESSION_ID: "some-session-id" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_TERMINAL_SESSION_UUID", () => { + withEnv({ WARP_TERMINAL_SESSION_UUID: "d0e1a2e5-7ca7-44cd-9037-ac7222011161" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("disables images for Warp inside tmux", () => { + withEnv( + { + TERM_PROGRAM: "WarpTerminal", + TMUX: "/tmp/tmux-1000/default,1234,0", + TERM: "tmux-256color", + }, + () => { + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.images, null); + assert.strictEqual(caps.hyperlinks, true); + }, + ); + }); + it("enables hyperlinks for iTerm2", () => { withEnv({ TERM_PROGRAM: "iterm.app" }, () => { const caps = detectCapabilities(); @@ -273,16 +329,27 @@ describe("detectCapabilities", () => { }); }); - it("detects truecolor for Windows Terminal outside multiplexers", () => { + it("enables truecolor and hyperlinks for Windows Terminal outside multiplexers", () => { withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => { const caps = detectCapabilities(); assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("enables truecolor without hyperlinks for JetBrains terminal", () => { + withEnv({ TERMINAL_EMULATOR: "JetBrains-JediTerm", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); }); }); it("does not inherit Windows Terminal truecolor through tmux", () => { withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); @@ -291,7 +358,7 @@ describe("detectCapabilities", () => { it("trusts explicit truecolor hints through tmux", () => { withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, true); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index ab2027df..a356bff1 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,6 +1,194 @@ import assert from "node:assert"; -import { describe, it } from "node:test"; -import { ProcessTerminal } from "../src/terminal.ts"; +import { describe, it, mock } from "node:test"; +import { setKittyProtocolActive } from "../src/keys.ts"; +import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; + +describe("normalizeAppleTerminalInput", () => { + it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r"); + }); + + it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeAppleTerminalInput("a", true, true), "a"); + }); +}); + +describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { + type NegotiationHarness = { + terminal: ProcessTerminal; + writes: string[]; + send(data: string): void; + getInput(): string | undefined; + cleanup(): void; + }; + + function setupNegotiation(): NegotiationHarness { + const terminal = new ProcessTerminal(); + const writes: string[] = []; + let input: string | undefined; + let dataHandler: ((data: string) => void) | undefined; + let cleaned = false; + const previousWrite = process.stdout.write; + const previousOn = process.stdin.on; + + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => { + if (event === "data") dataHandler = listener as (data: string) => void; + return process.stdin; + }) as typeof process.stdin.on; + + ( + terminal as unknown as { + inputHandler?: (data: string) => void; + queryAndEnableKittyProtocol(): void; + } + ).inputHandler = (data) => { + input = data; + }; + (terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + + return { + terminal, + writes, + send(data: string): void { + dataHandler?.(data); + }, + getInput(): string | undefined { + return input; + }, + cleanup(): void { + if (cleaned) return; + cleaned = true; + try { + terminal.stop(); + } finally { + process.stdout.write = previousWrite; + process.stdin.on = previousOn; + setKittyProtocolActive(false); + } + }, + }; + } + + it("queries Kitty mode before enabling modifyOtherKeys fallback", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("activates Kitty mode for non-zero negotiated flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7u"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, true); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.writes.includes("\x1b[>4;0m"), false); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[4;0m"), false); + } finally { + harness.cleanup(); + } + }); + + it("falls back to modifyOtherKeys for zero Kitty flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?0u"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("forwards normal input while waiting for Kitty response", () => { + const harness = setupNegotiation(); + try { + harness.send("a"); + + assert.equal(harness.getInput(), "a"); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("tracks split Kitty confirmation", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + harness.send("u"); + + assert.equal(harness.terminal.kittyProtocolActive, true); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays buffered CSI-prefix input when it is not a Kitty response", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b["); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(150); + + assert.equal(harness.getInput(), "\x1b["); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); +}); describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 230a0005..ab038ac7 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -1,7 +1,14 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { deleteKittyImage, encodeKitty } from "../src/terminal-image.ts"; +import { Image } from "../src/components/image.ts"; +import { + deleteKittyImage, + encodeKitty, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, +} from "../src/terminal-image.ts"; import { type Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -65,6 +72,175 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num } describe("TUI Kitty image cleanup", () => { + it("clears reserved Kitty image rows before drawing appended image placements", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok( + writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`), + "reserved rows should be cleared before the image placement is drawn", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[2K`), + "reserved row clears must not run after the image placement is drawn", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("falls back to full redraw when Kitty image pre-clear would scroll", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 2); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + component.lines = ["before", ...image.render(40), "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw"); + assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw"); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("reserves Kitty image rows before drawing during full redraw fallbacks", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["l0", "l1", "l2", "l3", "l4"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["l0", "l1", "l2", "l3", "l4", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(tui.fullRedraws > redrawsBeforeImage, "scrolling image append should force a full redraw"); + assert.ok( + writes.includes(`\r\n\r\n\x1b[2A${imageSequence}\x1b[2B`), + "full redraw should reserve visible image rows before drawing the placement", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[0m`), + "full redraw must not write reserved padding rows after drawing the placement", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("does not use cursor-up placement for Kitty images taller than the viewport", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 6 }, + { widthPx: 60, heightPx: 60 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + assert.ok(imageLines.length > terminal.rows, "test image should exceed the viewport height"); + + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(true); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes(imageSequence), "image placement should be drawn"); + assert.ok( + !writes.includes(`\x1b[${imageLines.length - 1}A${imageSequence}`), + "taller-than-viewport images must keep the #4461 first-row placement path", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + it("deletes changed image ids before drawing moved placements", async () => { const terminal = new LoggingVirtualTerminal(40, 10); const tui = new TUI(terminal); diff --git a/packages/tui/test/tui-shrink.test.ts b/packages/tui/test/tui-shrink.test.ts new file mode 100644 index 00000000..13fa5c4b --- /dev/null +++ b/packages/tui/test/tui-shrink.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class Lines implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +describe("TUI shrinking content", () => { + it("clears all rendered lines when content shrinks to zero", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const content = new Lines(["first", "second", "third"]); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("first"))); + assert.ok(terminal.getViewport().some((line) => line.includes("second"))); + assert.ok(terminal.getViewport().some((line) => line.includes("third"))); + + tui.clear(); + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared"); + + tui.stop(); + }); +}); diff --git a/packages/tui/test/word-navigation.test.ts b/packages/tui/test/word-navigation.test.ts new file mode 100644 index 00000000..2e58e960 --- /dev/null +++ b/packages/tui/test/word-navigation.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { findWordBackward, findWordForward } from "../src/word-navigation.ts"; + +describe("findWordBackward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordBackward(text, 11), 6); + assert.strictEqual(findWordBackward(text, 6), 0); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordBackward(text, 12), 8); + assert.strictEqual(findWordBackward(text, 8), 7); + // "/to" is one word-like segment with "/" as punctuation boundary + assert.strictEqual(findWordBackward(text, 7), 5); + assert.strictEqual(findWordBackward(text, 5), 4); + assert.strictEqual(findWordBackward(text, 4), 0); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + assert.strictEqual(findWordBackward(text, text.length), 5); + // Intl.Segmenter treats each CJK char as a separate word-like segment + assert.strictEqual(findWordBackward(text, 5), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordBackward(text, 9), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordBackward(text, 9), 6); + assert.strictEqual(findWordBackward(text, 6), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("cursor at 0 returns 0", () => { + assert.strictEqual(findWordBackward("hello", 0), 0); + }); +}); + +describe("findWordForward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordForward(text, 0), 5); + assert.strictEqual(findWordForward(text, 5), 11); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordForward(text, 0), 4); + assert.strictEqual(findWordForward(text, 4), 5); + assert.strictEqual(findWordForward(text, 5), 7); + assert.strictEqual(findWordForward(text, 7), 8); + assert.strictEqual(findWordForward(text, 8), 12); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + const firstEnd = findWordForward(text, 0); + assert.ok(firstEnd > 0); + assert.ok(firstEnd <= 4); + // Walk to end + let pos = 0; + while (pos < text.length) { + const next = findWordForward(text, pos); + if (next === pos) break; + pos = next; + } + assert.strictEqual(pos, text.length); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordForward(text, 0), 7); + assert.strictEqual(findWordForward(text, 7), 9); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 6); + assert.strictEqual(findWordForward(text, 6), 9); + }); + + it("cursor at end returns end", () => { + assert.strictEqual(findWordForward("hello", 5), 5); + }); +}); + +describe("atomic segments", () => { + const marker = "[paste #1 +5 lines]"; + const text = `hello ${marker} world`; + const isAtomic = (s: string) => s === marker; + + // The functions slice text before calling segment(), so we map each expected + // substring to its pre-split segments. + const segmentMap = new Map([ + [ + text, // full text (not used but for clarity) + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from end: slice(0, 31) = full text + text.slice(0, text.length), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] " + text.slice(0, 26), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + ], + ], + [ + // forward from 6: slice(6) = "[paste #1 +5 lines] world" + text.slice(6), + [ + { segment: marker, index: 0, input: text, isWordLike: true }, + { segment: " ", index: 19, input: text, isWordLike: false }, + { segment: "world", index: 20, input: text, isWordLike: true }, + ], + ], + ]); + + const opts = { + segment: (input: string) => segmentMap.get(input) ?? [], + isAtomicSegment: isAtomic, + }; + + it("backward skips word then stops before atomic marker", () => { + assert.strictEqual(findWordBackward(text, text.length, opts), 26); + }); + + it("backward skips whitespace then atomic marker as one unit", () => { + assert.strictEqual(findWordBackward(text, 26, opts), 6); + }); + + it("forward skips atomic marker as one unit", () => { + assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length); + }); +}); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 52d59148..a1183f75 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -111,6 +111,30 @@ describe("wrapTextWithAnsi", () => { } }); + it("should break CJK runs at grapheme boundaries after Latin text", () => { + const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容."; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + + it("should preserve color codes when wrapping CJK runs", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`); + assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + it("should ignore OSC 133 semantic markers in visible width", () => { const text = "\x1b]133;A\x07hello\x1b]133;B\x07"; assert.strictEqual(visibleWidth(text), 5); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c1b7b868..95c9bf82 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -4,11 +4,14 @@ # Mirrors .github/workflows/build-binaries.yml # # Usage: -# ./scripts/build-binaries.sh [--skip-deps] [--platform ] +# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--platform ] [--out ] # # Options: +# --skip-install Skip npm ci # --skip-deps Skip installing cross-platform dependencies +# --skip-build Skip npm run build # --platform Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64) +# --out Output directory (default: packages/coding-agent/binaries) # # Output: # packages/coding-agent/binaries/ @@ -23,19 +26,34 @@ set -euo pipefail cd "$(dirname "$0")/.." +SKIP_INSTALL=false SKIP_DEPS=false +SKIP_BUILD=false PLATFORM="" +OUTPUT_DIR="" while [[ $# -gt 0 ]]; do case $1 in + --skip-install) + SKIP_INSTALL=true + shift + ;; --skip-deps) SKIP_DEPS=true shift ;; + --skip-build) + SKIP_BUILD=true + shift + ;; --platform) PLATFORM="$2" shift 2 ;; + --out) + OUTPUT_DIR="$2" + shift 2 + ;; *) echo "Unknown option: $1" exit 1 @@ -56,45 +74,52 @@ if [[ -n "$PLATFORM" ]]; then esac fi -echo "==> Installing dependencies..." -npm ci --ignore-scripts +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="packages/coding-agent/binaries" +fi +if [[ "$OUTPUT_DIR" != /* ]]; then + OUTPUT_DIR="$(pwd)/$OUTPUT_DIR" +fi + +if [[ "$SKIP_INSTALL" == "false" ]]; then + echo "==> Installing dependencies..." + npm ci --ignore-scripts +else + echo "==> Skipping npm ci (--skip-install)" +fi if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." + CLIPBOARD_VERSION=$(node -p "require('./packages/coding-agent/package.json').optionalDependencies['@mariozechner/clipboard']") # npm ci only installs optional deps for the current platform - # We need all platform bindings for bun cross-compilation + # We need the base clipboard package and all platform bindings for bun cross-compilation # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Install all in one command to avoid npm removing packages from previous installs - npm install --no-save --package-lock=false --force --ignore-scripts \ - @mariozechner/clipboard-darwin-arm64@0.3.2 \ - @mariozechner/clipboard-darwin-x64@0.3.2 \ - @mariozechner/clipboard-linux-x64-gnu@0.3.2 \ - @mariozechner/clipboard-linux-arm64-gnu@0.3.2 \ - @mariozechner/clipboard-win32-x64-msvc@0.3.2 \ - @mariozechner/clipboard-win32-arm64-msvc@0.3.2 \ - @img/sharp-darwin-arm64@0.34.5 \ - @img/sharp-darwin-x64@0.34.5 \ - @img/sharp-linux-x64@0.34.5 \ - @img/sharp-linux-arm64@0.34.5 \ - @img/sharp-win32-x64@0.34.5 \ - @img/sharp-win32-arm64@0.34.5 \ - @img/sharp-libvips-darwin-arm64@1.2.4 \ - @img/sharp-libvips-darwin-x64@1.2.4 \ - @img/sharp-libvips-linux-x64@1.2.4 \ - @img/sharp-libvips-linux-arm64@1.2.4 + npm install --include=optional --no-save --package-lock=false --force --ignore-scripts \ + @mariozechner/clipboard@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-darwin-arm64@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-darwin-x64@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-linux-x64-gnu@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-linux-arm64-gnu@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-win32-x64-msvc@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-win32-arm64-msvc@"$CLIPBOARD_VERSION" else echo "==> Skipping cross-platform native bindings (--skip-deps)" fi -echo "==> Building all packages..." -npm run build +if [[ "$SKIP_BUILD" == "false" ]]; then + echo "==> Building all packages..." + npm run build +else + echo "==> Skipping package build (--skip-build)" +fi echo "==> Building binaries..." cd packages/coding-agent # Clean previous builds -rm -rf binaries -mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR"/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} # Determine which platforms to build if [[ -n "$PLATFORM" ]]; then @@ -105,10 +130,13 @@ fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." + # Bun compiled executables only embed worker scripts when they are passed as + # explicit build entrypoints. The runtime can still use new URL(...), but the + # worker must be present in the compiled executable. if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" fi done @@ -116,66 +144,94 @@ echo "==> Creating release archives..." # Copy shared files to each platform directory for platform in "${PLATFORMS[@]}"; do - cp package.json binaries/$platform/ - cp README.md binaries/$platform/ - cp CHANGELOG.md binaries/$platform/ - cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm binaries/$platform/ - mkdir -p binaries/$platform/theme - cp dist/modes/interactive/theme/*.json binaries/$platform/theme/ - mkdir -p binaries/$platform/assets - cp dist/modes/interactive/assets/* binaries/$platform/assets/ - cp -r dist/core/export-html binaries/$platform/ - cp -r docs binaries/$platform/ - cp -r examples binaries/$platform/ + cp package.json "$OUTPUT_DIR/$platform/" + cp README.md "$OUTPUT_DIR/$platform/" + cp CHANGELOG.md "$OUTPUT_DIR/$platform/" + cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm "$OUTPUT_DIR/$platform/" + mkdir -p "$OUTPUT_DIR/$platform/theme" + cp dist/modes/interactive/theme/*.json "$OUTPUT_DIR/$platform/theme/" + mkdir -p "$OUTPUT_DIR/$platform/assets" + cp dist/modes/interactive/assets/* "$OUTPUT_DIR/$platform/assets/" + cp -r dist/core/export-html "$OUTPUT_DIR/$platform/" + cp -r docs "$OUTPUT_DIR/$platform/" + cp -r examples "$OUTPUT_DIR/$platform/" - # Copy Windows VT input native helper next to compiled Windows binaries. + case "$platform" in + darwin-arm64) + clipboard_native_package="clipboard-darwin-arm64" + ;; + darwin-x64) + clipboard_native_package="clipboard-darwin-x64" + ;; + linux-x64) + clipboard_native_package="clipboard-linux-x64-gnu" + ;; + linux-arm64) + clipboard_native_package="clipboard-linux-arm64-gnu" + ;; + windows-x64) + clipboard_native_package="clipboard-win32-x64-msvc" + ;; + windows-arm64) + clipboard_native_package="clipboard-win32-arm64-msvc" + ;; + esac + mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner" + cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" + cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" + + # Copy terminal input native helpers next to compiled binaries. + if [[ "$platform" == darwin-* ]]; then + mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform" + cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/" + fi if [[ "$platform" == windows-* ]]; then if [[ "$platform" == "windows-arm64" ]]; then win32_arch_dir="win32-arm64" else win32_arch_dir="win32-x64" fi - mkdir -p binaries/$platform/native/win32/prebuilds/$win32_arch_dir - cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node binaries/$platform/native/win32/prebuilds/$win32_arch_dir/ + mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir" + cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/" fi done # Create archives -cd binaries +cd "$OUTPUT_DIR" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then # Windows (zip) echo "Creating pi-$platform.zip..." - (cd $platform && zip -r ../pi-$platform.zip .) + (cd "$platform" && zip -r ../pi-$platform.zip .) else # Unix platforms (tar.gz) - use wrapper directory for mise compatibility echo "Creating pi-$platform.tar.gz..." - mv $platform pi && tar -czf pi-$platform.tar.gz pi && mv pi $platform + mv "$platform" pi && tar -czf pi-$platform.tar.gz pi && mv pi "$platform" fi done # Extract archives for easy local testing echo "==> Extracting archives for testing..." for platform in "${PLATFORMS[@]}"; do - rm -rf $platform + rm -rf "$platform" if [[ "$platform" == windows-* ]]; then - mkdir -p $platform && (cd $platform && unzip -q ../pi-$platform.zip) + mkdir -p "$platform" && (cd "$platform" && unzip -q ../pi-$platform.zip) else - tar -xzf pi-$platform.tar.gz && mv pi $platform + tar -xzf pi-$platform.tar.gz && mv pi "$platform" fi done echo "" echo "==> Build complete!" -echo "Archives available in packages/coding-agent/binaries/" +echo "Archives available in $OUTPUT_DIR/" ls -lh *.tar.gz *.zip 2>/dev/null || true echo "" echo "Extracted directories for testing:" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then - echo " binaries/$platform/pi.exe" + echo " $OUTPUT_DIR/$platform/pi.exe" else - echo " binaries/$platform/pi" + echo " $OUTPUT_DIR/$platform/pi" fi done diff --git a/scripts/check-browser-smoke.mjs b/scripts/check-browser-smoke.mjs index d590988d..906eccd3 100644 --- a/scripts/check-browser-smoke.mjs +++ b/scripts/check-browser-smoke.mjs @@ -4,7 +4,21 @@ import { join } from "node:path"; import { build } from "esbuild"; const outputPath = join(tmpdir(), "pi-browser-smoke.js"); +const baseOutputPath = join(tmpdir(), "pi-browser-base-smoke.js"); +const selectiveOutputPath = join(tmpdir(), "pi-browser-selective-smoke.js"); const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log"); +const providerImplementationInputs = [ + "packages/ai/src/providers/amazon-bedrock.ts", + "packages/ai/src/providers/anthropic.ts", + "packages/ai/src/providers/azure-openai-responses.ts", + "packages/ai/src/providers/google.ts", + "packages/ai/src/providers/google-vertex.ts", + "packages/ai/src/providers/images/openrouter.ts", + "packages/ai/src/providers/mistral.ts", + "packages/ai/src/providers/openai-codex-responses.ts", + "packages/ai/src/providers/openai-completions.ts", + "packages/ai/src/providers/openai-responses.ts", +]; try { await build({ @@ -15,6 +29,36 @@ try { logLevel: "silent", outfile: outputPath, }); + const baseBuild = await build({ + stdin: { + contents: `import { complete } from "@earendil-works/pi-ai/base";\nimport { Agent } from "@earendil-works/pi-agent-core/base";\nconsole.log(typeof complete, typeof Agent);\n`, + resolveDir: process.cwd(), + sourcefile: "pi-browser-base-smoke-entry.ts", + }, + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + metafile: true, + outfile: baseOutputPath, + }); + const bundledInputs = new Set(Object.keys(baseBuild.metafile.inputs)); + const reachableProviderImplementations = providerImplementationInputs.filter((input) => bundledInputs.has(input)); + if (reachableProviderImplementations.length > 0) { + throw new Error(`Base browser bundle reached provider implementations:\n${reachableProviderImplementations.join("\n")}`); + } + await build({ + stdin: { + contents: `import { register as registerAnthropic } from "@earendil-works/pi-ai/anthropic";\nimport { register as registerOpenAICompletions } from "@earendil-works/pi-ai/openai-completions";\nimport { register as registerOpenRouterImages } from "@earendil-works/pi-ai/openrouter-images";\nconsole.log(typeof registerAnthropic, typeof registerOpenAICompletions, typeof registerOpenRouterImages);\n`, + resolveDir: process.cwd(), + sourcefile: "pi-browser-selective-smoke-entry.ts", + }, + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + outfile: selectiveOutputPath, + }); process.exit(0); } catch (error) { let detailedErrors = ""; diff --git a/scripts/check-lockfile-commit.mjs b/scripts/check-lockfile-commit.mjs index abf78947..bd77d609 100644 --- a/scripts/check-lockfile-commit.mjs +++ b/scripts/check-lockfile-commit.mjs @@ -30,30 +30,50 @@ function packageLabel(lockPath, entry) { return entry?.version ? `${name}@${entry.version}` : name; } -function summarizeLockfileChange() { +function getLockfilePackageChanges() { const before = readJsonFromGit("HEAD:package-lock.json"); const after = readJsonFromGit(":package-lock.json"); - if (!before?.packages || !after?.packages) return []; + if (!before?.packages || !after?.packages) return undefined; const changes = []; const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]); for (const lockPath of [...paths].sort()) { - if (!lockPath.includes("node_modules/")) continue; const oldEntry = before.packages[lockPath]; const newEntry = after.packages[lockPath]; - if (!oldEntry && newEntry) { - changes.push(`added ${packageLabel(lockPath, newEntry)}`); - } else if (oldEntry && !newEntry) { - changes.push(`removed ${packageLabel(lockPath, oldEntry)}`); - } else if (oldEntry?.version !== newEntry?.version) { - changes.push( - `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, - ); + if (JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) { + changes.push({ lockPath, oldEntry, newEntry }); } } return changes; } +function isWorkspacePackagePath(lockPath) { + return lockPath.startsWith("packages/"); +} + +function hasOnlyWorkspacePackageChanges(changes) { + return changes.length > 0 && changes.every((change) => isWorkspacePackagePath(change.lockPath)); +} + +function summarizeLockfileChange(changes) { + const nodeModuleChanges = changes.filter((change) => change.lockPath.includes("node_modules/")); + const summary = []; + for (const { lockPath, oldEntry, newEntry } of nodeModuleChanges) { + if (!oldEntry && newEntry) { + summary.push(`added ${packageLabel(lockPath, newEntry)}`); + } else if (oldEntry && !newEntry) { + summary.push(`removed ${packageLabel(lockPath, oldEntry)}`); + } else if (oldEntry?.version !== newEntry?.version) { + summary.push( + `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, + ); + } else { + summary.push(`changed ${packageLabel(lockPath, newEntry)}`); + } + } + return summary; +} + const stagedFiles = git(["diff", "--cached", "--name-only"]) .split("\n") .map((line) => line.trim()) @@ -68,6 +88,12 @@ if (allowed) { process.exit(0); } +const changes = getLockfilePackageChanges(); +if (changes && hasOnlyWorkspacePackageChanges(changes)) { + console.error("package-lock.json only updates workspace package metadata; allowing commit."); + process.exit(0); +} + console.error("package-lock.json is staged."); console.error(""); console.error("Review lockfile changes before committing:"); @@ -76,15 +102,15 @@ console.error(" - confirm npm age gates were active for resolution"); console.error(" - review any new lifecycle scripts in the dependency tree"); console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed"); -const changes = summarizeLockfileChange(); -if (changes.length > 0) { +const summary = changes ? summarizeLockfileChange(changes) : []; +if (summary.length > 0) { console.error(""); console.error("Detected package version changes:"); - for (const change of changes.slice(0, 40)) { + for (const change of summary.slice(0, 40)) { console.error(` - ${change}`); } - if (changes.length > 40) { - console.error(` ... ${changes.length - 40} more`); + if (summary.length > 40) { + console.error(` ... ${summary.length - 40} more`); } } diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index 7bcf5681..1971869c 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -12,7 +12,7 @@ const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); const internalPackagePrefix = "@earendil-works/pi-"; const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], - ["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"], + ["protobufjs@7.6.4", "postinstall only warns about protobufjs version scheme mismatches"], ]); const args = new Set(process.argv.slice(2)); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 782318bd..3f35e80c 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packages = [ @@ -131,48 +131,25 @@ function currentBinaryPlatform() { throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`); } -function copyBinaryAssets(targetDirectory) { - const codingAgentDirectory = join(repoRoot, "packages", "coding-agent"); - const distDirectory = join(codingAgentDirectory, "dist"); - cpSync(join(codingAgentDirectory, "package.json"), join(targetDirectory, "package.json")); - cpSync(join(codingAgentDirectory, "README.md"), join(targetDirectory, "README.md")); - cpSync(join(codingAgentDirectory, "CHANGELOG.md"), join(targetDirectory, "CHANGELOG.md")); - cpSync(join(repoRoot, "node_modules", "@silvia-odwyer", "photon-node", "photon_rs_bg.wasm"), join(targetDirectory, "photon_rs_bg.wasm")); - cpSync(join(distDirectory, "modes", "interactive", "theme"), join(targetDirectory, "theme"), { recursive: true }); - cpSync(join(distDirectory, "modes", "interactive", "assets"), join(targetDirectory, "assets"), { recursive: true }); - cpSync(join(distDirectory, "core", "export-html"), join(targetDirectory, "export-html"), { recursive: true }); - cpSync(join(codingAgentDirectory, "docs"), join(targetDirectory, "docs"), { recursive: true }); - cpSync(join(codingAgentDirectory, "examples"), join(targetDirectory, "examples"), { recursive: true }); -} - -function copyWindowsConsoleModeHelper(targetDirectory, platform) { - if (!platform.startsWith("windows-")) return; - const win32Arch = platform === "windows-arm64" ? "win32-arm64" : "win32-x64"; - const relativeNativePath = join("native", "win32", "prebuilds", win32Arch); - mkdirSync(join(targetDirectory, relativeNativePath), { recursive: true }); - cpSync( - join(repoRoot, "packages", "tui", "native", "win32", "prebuilds", win32Arch, "win32-console-mode.node"), - join(targetDirectory, relativeNativePath, "win32-console-mode.node"), - ); -} - function buildBunBinaryRelease(targetDirectory, archiveDirectory) { if (!commandExists("bun")) { throw new Error("Bun is required for the local binary release build."); } const platform = currentBinaryPlatform(); - mkdirSync(targetDirectory, { recursive: true }); - const executableName = platform.startsWith("windows-") ? "pi.exe" : "pi"; - const executablePath = join(targetDirectory, executableName); - const target = `bun-${platform}`; - run("bun", ["build", "--compile", `--target=${target}`, join(repoRoot, "packages", "coding-agent", "dist", "bun", "cli.js"), "--outfile", executablePath]); - copyBinaryAssets(targetDirectory); - copyWindowsConsoleModeHelper(targetDirectory, platform); - if (platform.startsWith("windows-")) { - run("powershell", ["-NoProfile", "-Command", `Compress-Archive -Path '${join(targetDirectory, "*").replaceAll("'", "''")}' -DestinationPath '${join(archiveDirectory, `pi-${platform}.zip`).replaceAll("'", "''")}' -Force`]); - } else { - run("tar", ["-czf", join(archiveDirectory, `pi-${platform}.tar.gz`), "-C", targetDirectory, "."]); - } + const binaryBuildDirectory = join(archiveDirectory, "binary-build"); + run("./scripts/build-binaries.sh", [ + "--skip-install", + "--skip-deps", + "--skip-build", + "--platform", + platform, + "--out", + binaryBuildDirectory, + ]); + rmSync(targetDirectory, { force: true, recursive: true }); + cpSync(join(binaryBuildDirectory, platform), targetDirectory, { recursive: true }); + const archiveName = platform.startsWith("windows-") ? `pi-${platform}.zip` : `pi-${platform}.tar.gz`; + cpSync(join(binaryBuildDirectory, archiveName), join(archiveDirectory, archiveName)); return platform; } diff --git a/scripts/publish.mjs b/scripts/publish.mjs new file mode 100644 index 00000000..513ab36f --- /dev/null +++ b/scripts/publish.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const packages = [ + { directory: "packages/ai", name: "@earendil-works/pi-ai" }, + { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { directory: "packages/tui", name: "@earendil-works/pi-tui" }, + { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, +]; + +const dryRun = process.argv.includes("--dry-run"); +const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run"); + +if (unknownArgs.length > 0) { + console.error(`Usage: node scripts/publish.mjs [--dry-run]`); + process.exit(1); +} + +function commandForPlatform(command) { + return process.platform === "win32" ? `${command}.cmd` : command; +} + +function run(command, args, options = {}) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(commandForPlatform(command), args, { + cwd: options.cwd, + encoding: "utf8", + stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); + } + + return result; +} + +function readPackageJson(directory) { + return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); +} + +function assertBuildOutputExists(directory) { + if (!existsSync(join(directory, "dist"))) { + throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`); + } +} + +function validatePack(directory) { + const result = run("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { capture: true, cwd: directory }); + const packed = JSON.parse(result.stdout)[0]; + console.log(` ${packed.filename}: ${packed.files.length} files, ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked`); +} + +function isPublished(name, version) { + const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], { + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }); + + if (result.status === 0 && result.stdout.trim()) { + return true; + } + + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) { + return false; + } + + throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`); +} + +const packageVersions = new Map(); +for (const pkg of packages) { + const packageJson = readPackageJson(pkg.directory); + if (packageJson.name !== pkg.name) { + throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); + } + packageVersions.set(pkg.name, packageJson.version); +} + +const versions = [...new Set(packageVersions.values())]; +if (versions.length !== 1) { + throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`); +} + +console.log(`Publishing pi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`); + +for (const pkg of packages) { + const version = packageVersions.get(pkg.name); + assertBuildOutputExists(pkg.directory); + const published = isPublished(pkg.name, version); + + if (dryRun) { + if (published) { + console.log(`${pkg.name}@${version} is already published; validating package contents only.`); + } else { + console.log(`${pkg.name}@${version} is not published; validating package contents before publish.`); + } + validatePack(pkg.directory); + console.log(); + continue; + } + + if (published) { + console.log(`Skipping ${pkg.name}@${version}: already published\n`); + continue; + } + + run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory }); + console.log(); +} diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 00000000..545bdc4f --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const DEFAULT_REPO = "earendil-works/pi"; +const DEFAULT_BASE_PATH = "packages/coding-agent"; +const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md"; +const DEFAULT_FIX_SINCE_TAG = "v0.74.0"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function printUsage() { + console.log(`Usage: node scripts/release-notes.mjs [options] + +Commands: + extract Extract release notes from the coding-agent changelog + fix-github-releases Rewrite existing GitHub release note links in place + +extract options: + --version Version to extract + --tag Release tag used for repository links (defaults to v) + --changelog Changelog path (default: ${DEFAULT_CHANGELOG}) + --out Output file (default: stdout) + --repo GitHub repository for generated links (default: ${DEFAULT_REPO}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + +fix-github-releases options: + --repo GitHub repository to patch (default: ${DEFAULT_REPO}) + --tag Patch only one release tag + --since-tag Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + --dry-run Print releases that would change without updating GitHub +`); +} + +function commandForPlatform(command) { + return process.platform === "win32" ? `${command}.cmd` : command; +} + +function run(command, args, options = {}) { + const result = spawnSync(commandForPlatform(command), args, { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, + stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); + } + + return result.stdout ?? ""; +} + +function parseOptions(args) { + const options = { + basePath: DEFAULT_BASE_PATH, + changelog: DEFAULT_CHANGELOG, + dryRun: false, + out: undefined, + repo: DEFAULT_REPO, + sinceTag: DEFAULT_FIX_SINCE_TAG, + tag: undefined, + version: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help") { + printUsage(); + process.exit(0); + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + + const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]); + if (!optionNames.has(arg)) { + throw new Error(`Unknown option: ${arg}`); + } + + const value = args[++i]; + if (!value) { + throw new Error(`${arg} requires a value`); + } + + if (arg === "--base-path") options.basePath = value; + if (arg === "--changelog") options.changelog = value; + if (arg === "--out") options.out = value; + if (arg === "--repo") options.repo = value; + if (arg === "--since-tag") options.sinceTag = value; + if (arg === "--tag") options.tag = value; + if (arg === "--version") options.version = value; + } + + return options; +} + +function normalizeTag(tagOrVersion) { + if (!tagOrVersion) { + return undefined; + } + return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`; +} + +function versionFromTag(tag) { + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function compareVersions(a, b) { + const aParts = versionFromTag(a).split(".").map(Number); + const bParts = versionFromTag(b).split(".").map(Number); + + for (let i = 0; i < 3; i++) { + const diff = (aParts[i] || 0) - (bParts[i] || 0); + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractChangelogSection(changelog, version) { + const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m"); + const heading = headingRe.exec(changelog); + + if (!heading) { + return ""; + } + + const sectionStart = heading.index + heading[0].length; + const rest = changelog.slice(sectionStart); + const nextHeading = rest.search(/^## \[/m); + const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); + return section.trim(); +} + +function splitLocalTarget(target) { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value) { + return value.replaceAll("\\", "/"); +} + +function normalizeBasePath(basePath) { + const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, ""); + return normalized === "." ? "" : normalized; +} + +function resolveRepositoryPath(targetPath, basePath) { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath, repositoryPath) { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeLinkTarget(target, options) { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`); + const repoUrl = `https://github.com/${options.repo}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart, options.basePath); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +function normalizeReleaseNoteLinks(markdown, options) { + const changes = []; + const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => { + const normalizedTarget = normalizeLinkTarget(target, options); + if (normalizedTarget !== target) { + changes.push({ from: target, to: normalizedTarget }); + } + return `${prefix}${normalizedTarget}${suffix}`; + }); + + return { changes, markdown: normalized }; +} + +function writeOutput(content, outPath) { + if (outPath) { + writeFileSync(outPath, content); + return; + } + + process.stdout.write(content); +} + +function extractReleaseNotes(options) { + const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined); + if (!version) { + throw new Error("extract requires --version or --tag"); + } + + if (!existsSync(options.changelog)) { + throw new Error(`Changelog does not exist: ${options.changelog}`); + } + + const tag = normalizeTag(options.tag ?? version); + const changelog = readFileSync(options.changelog, "utf8"); + const section = extractChangelogSection(changelog, version); + const rawNotes = section ? `${section}\n` : `Release ${version}\n`; + const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag }); + writeOutput(markdown, options.out); +} + +function listGithubReleases(repo) { + const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], { + capture: true, + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function uniqueChanges(changes) { + const seen = new Set(); + const unique = []; + for (const change of changes) { + const key = `${change.from}\n${change.to}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(change); + } + return unique; +} + +function updateGithubRelease(repo, tag, body) { + const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-")); + try { + const notesPath = path.join(tempDir, "notes.md"); + writeFileSync(notesPath, body); + run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true }); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +function fixGithubReleases(options) { + const tagFilter = normalizeTag(options.tag); + const sinceTag = normalizeTag(options.sinceTag); + const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter); + + if (tagFilter && matchingReleases.length === 0) { + throw new Error(`Release not found: ${tagFilter}`); + } + + const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0); + if (tagFilter && releases.length === 0) { + console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`); + console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`); + return; + } + + let changedCount = 0; + for (const release of releases) { + const tag = release.tag_name; + const body = release.body ?? ""; + const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag }); + if (result.markdown === body) { + continue; + } + + changedCount++; + const unique = uniqueChanges(result.changes); + console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`); + for (const change of unique) { + console.log(` ${change.from}`); + console.log(` -> ${change.to}`); + } + + if (!options.dryRun) { + updateGithubRelease(options.repo, tag, result.markdown); + } + } + + const prefix = options.dryRun ? "Would update" : "Updated"; + console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`); +} + +try { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help") { + printUsage(); + process.exit(command ? 0 : 1); + } + + const options = parseOptions(args); + if (command === "extract") { + extractReleaseNotes(options); + } else if (command === "fix-github-releases") { + fixGithubReleases(options); + } else { + throw new Error(`Unknown command: ${command}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 59b51509..c3ff6c7a 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,11 +10,12 @@ * 1. Check for uncommitted changes * 2. Bump version via npm run version:xxx or set an explicit version * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Generate the coding-agent npm-shrinkwrap.json - * 5. Commit and tag - * 6. Publish to npm + * 4. Regenerate release artifacts + * 5. Run checks + * 6. Commit and tag the release * 7. Add new [Unreleased] section to changelogs - * 8. Commit + * 8. Commit next-cycle changelog updates + * 9. Push main and the tag to trigger CI publishing */ import { execSync } from "child_process"; @@ -91,7 +92,7 @@ function bumpOrSetVersion(target) { } console.log(`Setting explicit version (${target})...`); - run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`); + run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`); return getVersion(); } @@ -163,23 +164,25 @@ console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Generate publish shrinkwrap -console.log("Generating coding-agent shrinkwrap..."); +// 4. Regenerate release artifacts +console.log("Regenerating release artifacts..."); +run("npm --prefix packages/ai run generate-models"); +run("npm --prefix packages/ai run generate-image-models"); run("npm run shrinkwrap:coding-agent"); console.log(); -// 5. Commit and tag +// 5. Run checks +console.log("Running checks..."); +run("npm run check"); +console.log(); + +// 6. Commit and tag console.log("Committing and tagging..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 6. Publish -console.log("Publishing to npm..."); -run("npm run publish"); -console.log(); - // 7. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); @@ -197,4 +200,4 @@ run("git push origin main"); run(`git push origin v${version}`); console.log(); -console.log(`=== Released v${version} ===`); +console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`); diff --git a/scripts/repro-5893-wsl-bash.mjs b/scripts/repro-5893-wsl-bash.mjs new file mode 100644 index 00000000..b5048de7 --- /dev/null +++ b/scripts/repro-5893-wsl-bash.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { createBashTool } from "../packages/coding-agent/src/core/tools/bash.ts"; + +const shellPath = "C:\\Windows\\System32\\bash.exe"; +const nameExpansion = "$" + "{name}"; +const countExpansion = "$" + "{count}"; +const iExpansion = "$" + "{i}"; + +function getTextOutput(result) { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +async function runCase(label, command, expectedOutput) { + const tool = createBashTool(process.cwd(), { shellPath }); + const result = await tool.execute(label, { command }); + const output = getTextOutput(result).trimEnd(); + if (output !== expectedOutput) { + throw new Error( + [ + `${label} failed`, + "Expected:", + expectedOutput, + "Actual:", + output, + ].join("\n"), + ); + } + console.log(output); +} + +if (process.platform !== "win32") { + throw new Error("This repro must run from Windows PowerShell/CMD, not macOS/Linux or inside WSL."); +} + +if (!existsSync(shellPath)) { + throw new Error(`WSL bash launcher not found at ${shellPath}. Install/enable WSL first.`); +} + +await runCase( + "issue-5893-simple-variable", + `name='World'; echo "Hello, ${nameExpansion}!"`, + "Hello, World!", +); + +await runCase( + "issue-5893-loop-variable", + `count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`, + "Iteration 1 of 3\nIteration 2 of 3\nIteration 3 of 3", +); + +console.log("issue #5893 WSL bash repro passed"); diff --git a/scripts/tool-stats.ts b/scripts/tool-stats.ts index 78a69418..742e0a83 100755 --- a/scripts/tool-stats.ts +++ b/scripts/tool-stats.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { spawn } from "node:child_process"; +import { openBrowser } from "../packages/coding-agent/src/utils/open-browser.ts"; interface TextContent { type: "text"; text: string } interface ImageContent { type: "image"; data: string; mimeType?: string } @@ -229,4 +229,4 @@ const html = ` mkdirSync(resolve(output, ".."), { recursive: true }); writeFileSync(output, html); console.log(`Wrote ${output}`); -spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? ["/c", "start", output] : [output], { detached: true, stdio: "ignore" }).unref(); +openBrowser(output); diff --git a/test.sh b/test.sh index 5f231845..9a553f6f 100755 --- a/test.sh +++ b/test.sh @@ -35,6 +35,7 @@ unset CEREBRAS_API_KEY unset XAI_API_KEY unset OPENROUTER_API_KEY unset ZAI_API_KEY +unset ZAI_CODING_CN_API_KEY unset MISTRAL_API_KEY unset MINIMAX_API_KEY unset MINIMAX_CN_API_KEY diff --git a/tsconfig.json b/tsconfig.json index 4dfbf3d2..bcb12eee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "*": ["./*"], "@earendil-works/pi-ai": ["./packages/ai/src/index.ts"], "@earendil-works/pi-ai/oauth": ["./packages/ai/src/oauth.ts"], + "@earendil-works/pi-ai/openrouter-images": ["./packages/ai/src/providers/images/openrouter.ts"], "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], @@ -21,5 +22,5 @@ } }, "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/coding-agent/examples/**/*"], - "exclude": ["**/dist/**"] + "exclude": ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"] }