diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml
index 0b248f11a5..db75ce10cd 100644
--- a/.github/actions/push-sandbox/action.yml
+++ b/.github/actions/push-sandbox/action.yml
@@ -77,6 +77,14 @@ runs:
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
+ - name: 'verify'
+ shell: 'bash'
+ run: |-
+ docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
+ set -e
+ node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
+ /usr/local/share/npm-global/bin/gemini --version >/dev/null
+ '
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh
index e6521376ce..92200ee4d2 100755
--- a/.github/scripts/pr-triage.sh
+++ b/.github/scripts/pr-triage.sh
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
- local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
+ local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
echo "${suffix%%|*}"
return
;;
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index 487225d452..4b37d0e109 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -224,8 +224,6 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
- continue-on-error: true
-
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -315,6 +313,7 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
+ - 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -323,6 +322,7 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
+ ${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f9714df99..dd7288cde5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -360,7 +360,6 @@ jobs:
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
- continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
@@ -458,6 +457,7 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
+ - 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,6 +468,7 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
+ (${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml
index b7a375d836..6f6767ebfe 100644
--- a/.github/workflows/evals-nightly.yml
+++ b/.github/workflows/evals-nightly.yml
@@ -27,6 +27,7 @@ jobs:
fail-fast: false
matrix:
model:
+ - 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml
index 64609b5c3b..9e50f11433 100644
--- a/.github/workflows/gemini-automated-issue-triage.yml
+++ b/.github/workflows/gemini-automated-issue-triage.yml
@@ -155,7 +155,10 @@ jobs:
"telemetry": {
"enabled": true,
"target": "gcp"
- }
+ },
+ "coreTools": [
+ "run_shell_command(echo)"
+ ],
}
prompt: |-
## Role
diff --git a/.github/workflows/pr-rate-limiter.yaml b/.github/workflows/pr-rate-limiter.yaml
new file mode 100644
index 0000000000..c703279532
--- /dev/null
+++ b/.github/workflows/pr-rate-limiter.yaml
@@ -0,0 +1,29 @@
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+
+name: 'PR rate limiter'
+
+permissions: {}
+
+on:
+ pull_request_target:
+ types:
+ - 'opened'
+ - 'reopened'
+
+jobs:
+ limit:
+ runs-on: 'gemini-cli-ubuntu-16-core'
+ permissions:
+ contents: 'read'
+ pull-requests: 'write'
+ steps:
+ - name: 'Limit open pull requests per user'
+ uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
+ with:
+ except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
+ comment-limit: 8
+ comment: >
+ You already have 7 pull requests open. Please work on getting
+ existing PRs merged before opening more.
+ close-limit: 8
+ close: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7dfe898f14..28e3c775d3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -372,8 +372,7 @@ specific debug settings.
### React DevTools
-To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
-used for the CLI's interface, is compatible with React DevTools version 4.x.
+To debug the CLI's React-based UI, you can use React DevTools.
1. **Start the Gemini CLI in development mode:**
@@ -381,20 +380,20 @@ used for the CLI's interface, is compatible with React DevTools version 4.x.
DEV=true npm start
```
-2. **Install and run React DevTools version 4.28.5 (or the latest compatible
- 4.x version):**
+2. **Install and run React DevTools version 6 (which matches the CLI's
+ `react-devtools-core`):**
You can either install it globally:
```bash
- npm install -g react-devtools@4.28.5
+ npm install -g react-devtools@6
react-devtools
```
Or run it directly using npx:
```bash
- npx react-devtools@4.28.5
+ npx react-devtools@6
```
Your running CLI application should then connect to React DevTools.
diff --git a/Dockerfile b/Dockerfile
index b41ea00368..25d27d46c6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,7 +42,10 @@ USER node
# install gemini-cli and clean up
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
-RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
+RUN npm install -g /tmp/gemini-core.tgz \
+ && npm install -g /tmp/gemini-cli.tgz \
+ && node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
+ && gemini --version > /dev/null \
&& npm cache clean --force \
&& rm -f /tmp/gemini-{cli,core}.tgz
diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md
index 4cb6a3824b..646106fa50 100644
--- a/docs/changelogs/preview.md
+++ b/docs/changelogs/preview.md
@@ -1,6 +1,6 @@
-# Preview release: v0.30.0-preview.3
+# Preview release: v0.30.0-preview.5
-Released: February 19, 2026
+Released: February 24, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -25,6 +25,10 @@ npm install -g @google/gemini-cli@preview
## What's Changed
+- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch
+ version v0.30.0-preview.4 and create version 0.30.0-preview.5 by
+ @gemini-cli-robot in
+ [#20086](https://github.com/google-gemini/gemini-cli/pull/20086)
- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
@gemini-cli-robot in
@@ -311,4 +315,4 @@ npm install -g @google/gemini-cli@preview
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
**Full changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
+https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5
diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md
index d5e78f6fb5..8e309f2a38 100644
--- a/docs/cli/plan-mode.md
+++ b/docs/cli/plan-mode.md
@@ -143,13 +143,27 @@ based on the task description.
### Customizing Policies
-Plan Mode is designed to be read-only by default to ensure safety during the
-research phase. However, you may occasionally need to allow specific tools to
-assist in your planning.
+Plan Mode's default tool restrictions are managed by the [policy engine] and
+defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
+enforces the read-only state, but you can customize these rules by creating your
+own policies in your `~/.gemini/policies/` directory (Tier 2).
-Because user policies (Tier 2) have a higher base priority than built-in
-policies (Tier 1), you can override Plan Mode's default restrictions by creating
-a rule in your `~/.gemini/policies/` directory.
+#### Example: Automatically approve read-only MCP tools
+
+By default, read-only MCP tools require user confirmation in Plan Mode. You can
+use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
+your specific environment.
+
+`~/.gemini/policies/mcp-read-only.toml`
+
+```toml
+[[rule]]
+mcpName = "*"
+toolAnnotations = { readOnlyHint = true }
+decision = "allow"
+priority = 100
+modes = ["plan"]
+```
#### Example: Allow git commands in Plan Mode
@@ -225,7 +239,7 @@ priority = 100
modes = ["plan"]
# Adjust the pattern to match your custom directory.
# This example matches any .md file in a .gemini/plans directory within the project.
-argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
+argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
@@ -243,3 +257,5 @@ argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
+[`plan.toml`]:
+ https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index 5011f55b2c..0b20ce31f2 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -29,6 +29,7 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
+| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
@@ -111,14 +112,15 @@ they appear in the UI.
### Security
-| UI Label | Setting | Description | Default |
-| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
-| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
-| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
-| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
-| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
-| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
+| UI Label | Setting | Description | Default |
+| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
+| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
+| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
+| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
+| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
+| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
+| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
+| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
### Advanced
@@ -135,6 +137,7 @@ they appear in the UI.
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
+| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
### Skills
diff --git a/docs/core/subagents.md b/docs/core/subagents.md
index 3619609e95..e84f46dd8c 100644
--- a/docs/core/subagents.md
+++ b/docs/core/subagents.md
@@ -80,6 +80,122 @@ Gemini CLI comes with the following built-in subagents:
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
+### Browser Agent (experimental)
+
+- **Name:** `browser_agent`
+- **Purpose:** Automate web browser tasks โ navigating websites, filling forms,
+ clicking buttons, and extracting information from web pages โ using the
+ accessibility tree.
+- **When to use:** "Go to example.com and fill out the contact form," "Extract
+ the pricing table from this page," "Click the login button and enter my
+ credentials."
+
+> **Note:** This is a preview feature currently under active development.
+
+#### Prerequisites
+
+The browser agent requires:
+
+- **Chrome** version 144 or later (any recent stable release will work).
+- **Node.js** with `npx` available (used to launch the
+ [`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
+ server).
+
+#### Enabling the browser agent
+
+The browser agent is disabled by default. Enable it in your `settings.json`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ }
+ }
+}
+```
+
+#### Session modes
+
+The `sessionMode` setting controls how Chrome is launched and managed. Set it
+under `agents.browser`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ },
+ "browser": {
+ "sessionMode": "persistent"
+ }
+ }
+}
+```
+
+The available modes are:
+
+| Mode | Description |
+| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
+| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
+| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
+
+#### Configuration reference
+
+All browser-specific settings go under `agents.browser` in your `settings.json`.
+
+| Setting | Type | Default | Description |
+| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
+| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
+| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
+| `profilePath` | `string` | โ | Custom path to a browser profile directory. |
+| `visualModel` | `string` | โ | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
+
+#### Security
+
+The browser agent enforces the following security restrictions:
+
+- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
+ `chrome://extensions`, and `chrome://settings/passwords` are always blocked.
+- **Sensitive action confirmation:** Actions like form filling, file uploads,
+ and form submissions require user confirmation through the standard policy
+ engine.
+
+#### Visual agent
+
+By default, the browser agent interacts with pages through the accessibility
+tree using element `uid` values. For tasks that require visual identification
+(for example, "click the yellow button" or "find the red error message"), you
+can enable the visual agent by setting a `visualModel`:
+
+```json
+{
+ "agents": {
+ "overrides": {
+ "browser_agent": {
+ "enabled": true
+ }
+ },
+ "browser": {
+ "visualModel": "gemini-2.5-computer-use-preview-10-2025"
+ }
+ }
+}
+```
+
+When enabled, the agent gains access to the `analyze_screenshot` tool, which
+captures a screenshot and sends it to the vision model for analysis. The model
+returns coordinates and element descriptions that the browser agent uses with
+the `click_at` tool for precise, coordinate-based interactions.
+
+> **Note:** The visual agent requires API key or Vertex AI authentication. It is
+> not available when using Google Login.
+
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce
diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md
index e78480df2f..cebb675108 100644
--- a/docs/extensions/reference.md
+++ b/docs/extensions/reference.md
@@ -116,7 +116,9 @@ The manifest file defines the extension's behavior and configuration.
"description": "My awesome extension",
"mcpServers": {
"my-server": {
- "command": "node my-server.js"
+ "command": "node",
+ "args": ["${extensionPath}/my-server.js"],
+ "cwd": "${extensionPath}"
}
},
"contextFileName": "GEMINI.md",
@@ -125,19 +127,41 @@ The manifest file defines the extension's behavior and configuration.
}
```
-- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
- and dashes. This name must match the extension's directory name.
-- `version`: The current version of the extension.
-- `description`: A short summary shown in the extension gallery.
-- `mcpServers`: A map of Model Context Protocol (MCP)
- servers. Extension servers follow the same format as standard
- [CLI configuration](../reference/configuration.md).
-- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
- also be an array of strings to load multiple context files.
-- `excludeTools`: An array of tools to block from the model. You can restrict
- specific arguments, such as `run_shell_command(rm -rf)`.
-- `themes`: An optional list of themes provided by the extension. See
- [Themes](../cli/themes.md) for more information.
+- `name`: The name of the extension. This is used to uniquely identify the
+ extension and for conflict resolution when extension commands have the same
+ name as user or project commands. The name should be lowercase or numbers and
+ use dashes instead of underscores or spaces. This is how users will refer to
+ your extension in the CLI. Note that we expect this name to match the
+ extension directory name.
+- `version`: The version of the extension.
+- `description`: A short description of the extension. This will be displayed on
+ [geminicli.com/extensions](https://geminicli.com/extensions).
+- `mcpServers`: A map of MCP servers to settings. The key is the name of the
+ server, and the value is the server configuration. These servers will be
+ loaded on startup just like MCP servers defined in a
+ [`settings.json` file](../reference/configuration.md). If both an extension
+ and a `settings.json` file define an MCP server with the same name, the server
+ defined in the `settings.json` file takes precedence.
+ - Note that all MCP server configuration options are supported except for
+ `trust`.
+ - For portability, you should use `${extensionPath}` to refer to files within
+ your extension directory.
+ - Separate your executable and its arguments using `command` and `args`
+ instead of putting them both in `command`.
+- `contextFileName`: The name of the file that contains the context for the
+ extension. This will be used to load the context from the extension directory.
+ If this property is not used but a `GEMINI.md` file is present in your
+ extension directory, then that file will be loaded.
+- `excludeTools`: An array of tool names to exclude from the model. You can also
+ specify command-specific restrictions for tools that support it, like the
+ `run_shell_command` tool. For example,
+ `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
+ command. Note that this differs from the MCP server `excludeTools`
+ functionality, which can be listed in the MCP server config.
+
+When Gemini CLI starts, it loads all the extensions and merges their
+configurations. If there are any conflicts, the workspace configuration takes
+precedence.
### Extension settings
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index 4d0158b71f..bc29581d2f 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -64,6 +64,16 @@ and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
+## Check usage and quota
+
+You can check your current token usage and quota information using the
+`/stats model` command. This command provides a snapshot of your current
+session's token usage, as well as your overall quota and usage for the supported
+models.
+
+For more information on the `/stats` command and its subcommands, see the
+[Command Reference](../../reference/commands.md#stats).
+
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md
index 452edb378d..9b7226ac05 100644
--- a/docs/hooks/reference.md
+++ b/docs/hooks/reference.md
@@ -98,6 +98,8 @@ and parameter rewriting.
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
@@ -120,12 +122,18 @@ hiding sensitive output from the agent.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
+ - `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
+ A request to execute another tool immediately after this one. The result of
+ this "tail call" will replace the original tool's response. Ideal for
+ programmatic tool routing.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md
index c187a92f37..f16be2e730 100644
--- a/docs/ide-integration/index.md
+++ b/docs/ide-integration/index.md
@@ -170,6 +170,20 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
+### Manual PID override
+
+If automatic IDE detection fails, or if you are running Gemini CLI in a
+standalone terminal and want to manually associate it with a specific IDE
+instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
+process ID (PID) of your IDE.
+
+```bash
+export GEMINI_CLI_IDE_PID=12345
+```
+
+When this variable is set, Gemini CLI will skip automatic detection and attempt
+to connect using the provided PID.
+
### Configuration errors
- **Message:**
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index ee7ac6d581..ceb064a9bf 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
conversation state interactively, or resuming a previous state from a later
session.
- **Sub-commands:**
+ - **`debug`**
+ - **Description:** Export the most recent API request as a JSON payload.
- **`delete `**
- **Description:** Deletes a saved conversation checkpoint.
- **`list`**
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
### `/extensions`
-- **Description:** Lists all active extensions in the current Gemini CLI
- session. See [Gemini CLI Extensions](../extensions/index.md).
+- **Description:** Manage extensions. See
+ [Gemini CLI Extensions](../extensions/index.md).
+- **Sub-commands:**
+ - **`config`**:
+ - **Description:** Configure extension settings.
+ - **`disable`**:
+ - **Description:** Disable an extension.
+ - **`enable`**:
+ - **Description:** Enable an extension.
+ - **`explore`**:
+ - **Description:** Open extensions page in your browser.
+ - **`install`**:
+ - **Description:** Install an extension from a git repo or local path.
+ - **`link`**:
+ - **Description:** Link an extension from a local path.
+ - **`list`**:
+ - **Description:** List active extensions.
+ - **`restart`**:
+ - **Description:** Restart all extensions.
+ - **`uninstall`**:
+ - **Description:** Uninstall an extension.
+ - **`update`**:
+ - **Description:** Update extensions. Usage: update |--all
### `/help` (or `/?`)
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
servers that support OAuth authentication.
- **`desc`**
- **Description:** List configured MCP servers and tools with descriptions.
+ - **`disable`**
+ - **Description:** Disable an MCP server.
+ - **`enable`**
+ - **Description:** Enable a disabled MCP server.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
### `/model`
-- **Description:** Opens a dialog to choose your Gemini model.
+- **Description:** Manage model configuration.
+- **Sub-commands:**
+ - **`manage`**:
+ - **Description:** Opens a dialog to configure the model.
+ - **`set`**:
+ - **Description:** Set the model to use.
+ - **Usage:** `/model set [--persist]`
+
+### `/permissions`
+
+- **Description:** Manage folder trust settings and other permissions.
+- **Sub-commands:**
+ - **`trust`**:
+ - **Description:** Manage folder trust settings.
+ - **Usage:** `/permissions trust []`
### `/plan`
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
### `/stats`
- **Description:** Display detailed statistics for the current Gemini CLI
- session, including token usage, cached token savings (when available), and
- session duration. Note: Cached token information is only displayed when cached
- tokens are being used, which occurs with API key authentication but not with
- OAuth authentication at this time.
+ session.
+- **Sub-commands:**
+ - **`session`**:
+ - **Description:** Show session-specific usage statistics, including
+ duration, tool calls, and performance metrics. This is the default view.
+ - **`model`**:
+ - **Description:** Show model-specific usage statistics, including token
+ counts and quota information.
+ - **`tools`**:
+ - **Description:** Show tool-specific usage statistics.
### `/terminal-setup`
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index b9874e017b..6bf28215c1 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -142,6 +142,11 @@ their corresponding top-level category object in your `settings.json` file.
request" errors.
- **Default:** `false`
+- **`general.maxAttempts`** (number):
+ - **Description:** Maximum number of attempts for requests to the main chat
+ model. Cannot exceed 10.
+ - **Default:** `10`
+
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -641,6 +646,27 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `{}`
- **Requires restart:** Yes
+- **`agents.browser.sessionMode`** (enum):
+ - **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
+ - **Default:** `"persistent"`
+ - **Values:** `"persistent"`, `"isolated"`, `"existing"`
+ - **Requires restart:** Yes
+
+- **`agents.browser.headless`** (boolean):
+ - **Description:** Run browser in headless mode.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
+- **`agents.browser.profilePath`** (string):
+ - **Description:** Path to browser profile directory for session persistence.
+ - **Default:** `undefined`
+ - **Requires restart:** Yes
+
+- **`agents.browser.visualModel`** (string):
+ - **Description:** Model override for the visual agent.
+ - **Default:** `undefined`
+ - **Requires restart:** Yes
+
#### `context`
- **`context.fileName`** (string | string[]):
@@ -868,6 +894,14 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
+- **`security.enableConseca`** (boolean):
+ - **Description:** Enable the context-aware security checker. This feature
+ uses an LLM to dynamically generate and enforce security policies for tool
+ use based on your prompt, providing an additional layer of protection
+ against unintended actions.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
@@ -969,6 +1003,11 @@ their corresponding top-level category object in your `settings.json` file.
during tool execution.
- **Default:** `false`
+- **`experimental.directWebFetch`** (boolean):
+ - **Description:** Enable web fetch behavior that bypasses LLM summarization.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
#### `skills`
- **`skills.enabled`** (boolean):
@@ -1256,6 +1295,11 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
+- **`GEMINI_CLI_IDE_PID`**:
+ - Manually specifies the PID of the IDE process to use for integration. This
+ is useful when running Gemini CLI in a standalone terminal while still
+ wanting to associate it with a specific IDE instance.
+ - Overrides the automatic IDE detection logic.
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md
index 2106b751c9..a123634581 100644
--- a/docs/reference/policy-engine.md
+++ b/docs/reference/policy-engine.md
@@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments.
The `toolName` in the rule must match the name of the tool being called.
-- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
- wildcard. A `toolName` of `my-server__*` will match any tool from the
- `my-server` MCP.
+- **Wildcards**: You can use wildcards to match multiple tools.
+ - `*`: Matches **any tool** (built-in or MCP).
+ - `server__*`: Matches any tool from a specific MCP server.
+ - `*__toolName`: Matches a specific tool name across **all** MCP servers.
+ - `*__*`: Matches **any tool from any MCP server**.
#### Arguments pattern
@@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met:
1. **Tool name**: The `toolName` in the rule must match the name of the tool
being called.
- - **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
- wildcard. A `toolName` of `my-server__*` will match any tool from the
- `my-server` MCP.
+ - **Wildcards**: You can use wildcards like `*`, `server__*`, or
+ `*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
+ details.
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
@@ -203,6 +205,10 @@ toolName = "run_shell_command"
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
+# (Optional) Metadata hints provided by the tool. A rule matches if all
+# key-value pairs provided here are present in the tool's annotations.
+toolAnnotations = { readOnlyHint = true }
+
# (Optional) A regex to match against the tool's arguments.
argsPattern = '"command":"(git|npm)'
@@ -272,13 +278,12 @@ priority = 100
### Special syntax for MCP tools
-You can create rules that target tools from Model-hosting-protocol (MCP) servers
-using the `mcpName` field or a wildcard pattern.
+You can create rules that target tools from Model Context Protocol (MCP) servers
+using the `mcpName` field or composite wildcard patterns.
-**1. Using `mcpName`**
+**1. Targeting a specific tool on a server**
-To target a specific tool from a specific server, combine `mcpName` and
-`toolName`.
+Combine `mcpName` and `toolName` to target a single operation.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
@@ -289,10 +294,10 @@ decision = "allow"
priority = 200
```
-**2. Using a wildcard**
+**2. Targeting all tools on a specific server**
-To create a rule that applies to _all_ tools on a specific MCP server, specify
-only the `mcpName`.
+Specify only the `mcpName` to apply a rule to every tool provided by that
+server.
```toml
# Denies all tools from the `untrusted-server` MCP
@@ -303,6 +308,33 @@ priority = 500
deny_message = "This server is not trusted by the admin."
```
+**3. Targeting all MCP servers**
+
+Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
+registered MCP server. This is useful for setting category-wide defaults.
+
+```toml
+# Ask user for any tool call from any MCP server
+[[rule]]
+mcpName = "*"
+decision = "ask_user"
+priority = 10
+```
+
+**4. Targeting a tool name across all servers**
+
+Use `mcpName = "*"` with a specific `toolName` to target that operation
+regardless of which server provides it.
+
+```toml
+# Allow the `search` tool across all connected MCP servers
+[[rule]]
+mcpName = "*"
+toolName = "search"
+decision = "allow"
+priority = 50
+```
+
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md
index 7b1b37a32c..d4ed22a1cb 100644
--- a/docs/resources/quota-and-pricing.md
+++ b/docs/resources/quota-and-pricing.md
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
+## Check usage and quota
+
+You can check your current token usage and quota information using the
+`/stats model` command. This command provides a snapshot of your current
+session's token usage, as well as your overall quota and usage for the supported
+models.
+
+For more information on the `/stats` command and its subcommands, see the
+[Command Reference](../../reference/commands.md#stats).
+
+A summary of model usage is also presented on exit at the end of a session.
+
## Tips to avoid high costs
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
@@ -151,8 +163,3 @@ costs.
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
-
-## Understanding your usage
-
-A summary of model usage is available through the `/stats` command and presented
-on exit at the end of a session.
diff --git a/docs/sidebar.json b/docs/sidebar.json
index 1a47f8adc9..8a4bd7391c 100644
--- a/docs/sidebar.json
+++ b/docs/sidebar.json
@@ -72,14 +72,9 @@
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
- { "label": "Help", "link": "/docs/reference/commands/#help-or" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
- {
- "label": "Memory management",
- "link": "/docs/reference/commands/#memory"
- },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "๐งช", "slug": "docs/cli/plan-mode" },
@@ -96,17 +91,8 @@
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
- {
- "label": "Shell",
- "link": "/docs/reference/commands/#shells-or-bashes"
- },
- {
- "label": "Stats",
- "link": "/docs/reference/commands/#stats"
- },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
- { "label": "Token caching", "slug": "docs/cli/token-caching" },
- { "label": "Tools", "link": "/docs/reference/commands/#tools" }
+ { "label": "Token caching", "slug": "docs/cli/token-caching" }
]
},
{
diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md
index c2c29c6963..09c792f84d 100644
--- a/docs/tools/file-system.md
+++ b/docs/tools/file-system.md
@@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers.
## 6. `replace` (Edit)
-`replace` replaces text within a file. By default, replaces a single occurrence,
-but can replace multiple occurrences when `expected_replacements` is specified.
-This tool is designed for precise, targeted changes and requires significant
-context around the `old_string` to ensure it modifies the correct location.
+`replace` replaces text within a file. By default, the tool expects to find and
+replace exactly ONE occurrence of `old_string`. If you want to replace multiple
+occurrences of the exact same string, set `allow_multiple` to `true`. This tool
+is designed for precise, targeted changes and requires significant context
+around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `replace`
- **Arguments:**
@@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location.
- `instruction` (string, required): Semantic description of the change.
- `old_string` (string, required): Exact literal text to find.
- `new_string` (string, required): Exact literal text to replace with.
+ - `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
+ If `false` (default), only succeeds if exactly one occurrence is found.
- **Confirmation:** Requires manual user approval.
## Next steps
diff --git a/docs/tools/index.md b/docs/tools/index.md
index f496ad591a..6bdf298fea 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -52,6 +52,9 @@ These tools help the model manage its plan and interact with you.
complex plans.
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
procedural expertise when needed.
+- **[Browser agent](../core/subagents.md#browser-agent-experimental)
+ (`browser_agent`):** Automates web browser tasks through the accessibility
+ tree.
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
documentation to help answer your questions.
diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md
index 09726432fd..22ce748918 100644
--- a/docs/tools/mcp-server.md
+++ b/docs/tools/mcp-server.md
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can
- reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
+ reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
+ platforms), or `%VAR_NAME%` (Windows only).
- **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes)
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`.
+### Environment variable expansion
+
+Gemini CLI automatically expands environment variables in the `env` block of
+your MCP server configuration. This allows you to securely reference variables
+defined in your shell or environment without hardcoding sensitive information
+directly in your `settings.json` file.
+
+The expansion utility supports:
+
+- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
+ all platforms)
+- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
+
+If a variable is not defined in the current environment, it resolves to an empty
+string.
+
+**Example:**
+
+```json
+"env": {
+ "API_KEY": "$MY_EXTERNAL_TOKEN",
+ "LOG_LEVEL": "$LOG_LEVEL",
+ "TEMP_DIR": "%TEMP%"
+}
+```
+
+### Security and environment sanitization
+
+To protect your credentials, Gemini CLI performs environment sanitization when
+spawning MCP server processes.
+
+#### Automatic redaction
+
+By default, the CLI redacts sensitive environment variables from the base
+environment (inherited from the host process) to prevent unintended exposure to
+third-party MCP servers. This includes:
+
+- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
+- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
+ `*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
+- Certificates and private key patterns.
+
+#### Explicit overrides
+
+If an environment variable must be passed to an MCP server, you must explicitly
+state it in the `env` property of the server configuration in `settings.json`.
+Explicitly defined variables (including those from extensions) are trusted and
+are **not** subjected to the automatic redaction process.
+
+This follows the security principle that if a variable is explicitly configured
+by the user for a specific server, it constitutes informed consent to share that
+specific data with that server.
+
+> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
+> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
+> securely pull the value from your host environment at runtime.
+
### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables
- containing API keys or tokens
+ containing API keys or tokens. See
+ [Security and environment sanitization](#security-and-environment-sanitization)
+ for details on how Gemini CLI protects your credentials.
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to
diff --git a/evals/frugalReads.eval.ts b/evals/frugalReads.eval.ts
index 55a73f85e2..47578039a6 100644
--- a/evals/frugalReads.eval.ts
+++ b/evals/frugalReads.eval.ts
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
).toBe(true);
let totalLinesRead = 0;
- const readRanges: { offset: number; limit: number }[] = [];
+ const readRanges: { start_line: number; end_line: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent read the entire file (missing limit) instead of using ranged read',
+ args.end_line,
+ 'Agent read the entire file (missing end_line) instead of using ranged read',
).toBeDefined();
- const limit = args.limit;
- const offset = args.offset ?? 0;
- totalLinesRead += limit;
- readRanges.push({ offset, limit });
+ const end_line = args.end_line;
+ const start_line = args.start_line ?? 1;
+ const linesRead = end_line - start_line + 1;
+ totalLinesRead += linesRead;
+ readRanges.push({ start_line, end_line });
- expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
+ expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
- (range) => line >= range.offset && line < range.offset + range.limit,
+ (range) => line >= range.start_line && line <= range.end_line,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent should have used ranged read (limit) to save tokens',
+ args.end_line,
+ 'Agent should have used ranged read (end_line) to save tokens',
).toBeDefined();
}
},
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
- return args.limit === undefined;
+ return args.end_line === undefined;
});
expect(
diff --git a/evals/frugalSearch.eval.ts b/evals/frugalSearch.eval.ts
index 8805a6a8ed..1c49fc2ed4 100644
--- a/evals/frugalSearch.eval.ts
+++ b/evals/frugalSearch.eval.ts
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
- (args.limit === undefined || args.limit === null)
+ (args.end_line === undefined || args.end_line === null)
);
});
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
- args.limit !== undefined
+ args.end_line !== undefined
) {
return true;
}
diff --git a/evals/interactive-hang.eval.ts b/evals/interactive-hang.eval.ts
index 43b49759bb..0cf56acf98 100644
--- a/evals/interactive-hang.eval.ts
+++ b/evals/interactive-hang.eval.ts
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
const scaffoldCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
- /npm (init|create)|npx create-|yarn create|pnpm create/.test(
+ /npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
l.toolRequest.args,
),
);
diff --git a/integration-tests/hooks-system.tail-tool-call.responses b/integration-tests/hooks-system.tail-tool-call.responses
new file mode 100644
index 0000000000..13dc3fde4d
--- /dev/null
+++ b/integration-tests/hooks-system.tail-tool-call.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
\ No newline at end of file
diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts
index 2db1019c5f..479851957b 100644
--- a/integration-tests/hooks-system.test.ts
+++ b/integration-tests/hooks-system.test.ts
@@ -286,6 +286,113 @@ describe('Hooks System Integration', () => {
});
});
+ describe('Command Hooks - Tail Tool Calls', () => {
+ it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
+ // Create a script that acts as the hook.
+ // It will trigger on "read_file" and issue a tail call to "write_file".
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ });
+
+ const hookOutput = {
+ decision: 'allow',
+ hookSpecificOutput: {
+ hookEventName: 'AfterTool',
+ tailToolCallRequest: {
+ name: 'write_file',
+ args: {
+ file_path: 'tail-called-file.txt',
+ content: 'Content from tail call',
+ },
+ },
+ },
+ };
+
+ const hookScript = `console.log(JSON.stringify(${JSON.stringify(
+ hookOutput,
+ )})); process.exit(0);`;
+
+ const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
+ writeFileSync(scriptPath, hookScript);
+ const commandPath = scriptPath.replace(/\\/g, '/');
+
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ settings: {
+ hooksConfig: {
+ enabled: true,
+ },
+ hooks: {
+ AfterTool: [
+ {
+ matcher: 'read_file',
+ hooks: [
+ {
+ type: 'command',
+ command: `node "${commandPath}"`,
+ timeout: 5000,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+
+ // Create a test file to trigger the read_file tool
+ rig.createFile('original.txt', 'Original content');
+
+ const cliOutput = await rig.run({
+ args: 'Read original.txt', // Fake responses should trigger read_file on this
+ });
+
+ // 1. Verify that write_file was called (as a tail call replacing read_file)
+ // Since read_file was replaced before finalizing, it will not appear in the tool logs.
+ const foundWriteFile = await rig.waitForToolCall('write_file');
+ expect(foundWriteFile).toBeTruthy();
+
+ // Ensure hook logs are flushed and the final LLM response is received.
+ // The mock LLM is configured to respond with "Tail call completed successfully."
+ expect(cliOutput).toContain('Tail call completed successfully.');
+
+ // Ensure telemetry is written to disk
+ await rig.waitForTelemetryReady();
+
+ // Read hook logs to debug
+ const hookLogs = rig.readHookLogs();
+ const relevantHookLog = hookLogs.find(
+ (l) => l.hookCall.hook_event_name === 'AfterTool',
+ );
+
+ expect(relevantHookLog).toBeDefined();
+
+ // 2. Verify write_file was executed.
+ // In non-interactive mode, the CLI deduplicates tool execution logs by callId.
+ // Since a tail call reuses the original callId, "Tool: write_file" is not printed.
+ // Instead, we verify the side-effect (file creation) and the telemetry log.
+
+ // 3. Verify the tail-called tool actually wrote the file
+ const modifiedContent = rig.readFile('tail-called-file.txt');
+ expect(modifiedContent).toBe('Content from tail call');
+
+ // 4. Verify telemetry for the final tool call.
+ // The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
+ const toolLogs = rig.readToolLogs();
+ const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
+ expect(
+ successfulTools.some((t) => t.toolRequest.name === 'write_file'),
+ ).toBeTruthy();
+ // The original request name should be preserved in the log payload if possible,
+ // but the executed tool name is 'write_file'.
+ });
+ });
+
describe('BeforeModel Hooks - LLM Request Modification', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
diff --git a/package-lock.json b/package-lock.json
index ec22d4cd4d..8b12e7f0f3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
+ "node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
@@ -37,6 +38,7 @@
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
+ "domexception": "^4.0.0",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
@@ -997,9 +999,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
- "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1029,9 +1031,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1039,13 +1041,13 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz",
- "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.6",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
@@ -1054,19 +1056,22 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
- "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
- "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1077,20 +1082,20 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
+ "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1114,9 +1119,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
- "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
+ "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1127,9 +1132,9 @@
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
- "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1137,32 +1142,19 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
- "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.2",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
- "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
"node_modules/@google-cloud/common": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
@@ -1343,9 +1335,9 @@
}
},
"node_modules/@google-cloud/storage": {
- "version": "7.17.0",
- "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
- "integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
+ "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
@@ -1354,7 +1346,7 @@
"abort-controller": "^3.0.0",
"async-retry": "^1.3.3",
"duplexify": "^4.1.3",
- "fast-xml-parser": "^4.4.1",
+ "fast-xml-parser": "^5.3.4",
"gaxios": "^6.0.2",
"google-auth-library": "^9.6.3",
"html-entities": "^2.5.2",
@@ -1761,27 +1753,6 @@
}
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
- "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2166,9 +2137,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3452,9 +3423,9 @@
}
},
"node_modules/@secretlint/config-loader/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4361,21 +4332,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
- "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/type-utils": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4385,9 +4355,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.35.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -4401,18 +4371,18 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz",
- "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4422,20 +4392,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz",
- "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.35.0",
- "@typescript-eslint/types": "^8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4445,18 +4415,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz",
- "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4467,9 +4437,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz",
- "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4480,20 +4450,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz",
- "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4503,14 +4474,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz",
- "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4522,22 +4493,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz",
- "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.35.0",
- "@typescript-eslint/tsconfig-utils": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4547,46 +4517,59 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz",
- "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4596,19 +4579,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz",
- "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4618,6 +4601,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz",
@@ -4694,174 +4690,6 @@
}
}
},
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
- "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.47.0",
- "@typescript-eslint/types": "^8.47.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
- "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
- "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
- "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
- "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/project-service": "8.47.0",
- "@typescript-eslint/tsconfig-utils": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
- "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/typescript-estree": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
- "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "eslint-visitor-keys": "^4.2.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -4971,17 +4799,17 @@
}
},
"node_modules/@vscode/vsce": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
- "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
+ "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
- "@secretlint/node": "^10.1.1",
- "@secretlint/secretlint-formatter-sarif": "^10.1.1",
- "@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
- "@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
+ "@secretlint/node": "^10.1.2",
+ "@secretlint/secretlint-formatter-sarif": "^10.1.2",
+ "@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
+ "@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
@@ -4998,7 +4826,7 @@
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
- "secretlint": "^10.1.1",
+ "secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
@@ -5162,6 +4990,70 @@
"win32"
]
},
+ "node_modules/@vscode/vsce/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "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": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5369,9 +5261,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5403,9 +5295,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -5834,18 +5726,6 @@
"url": "https://bevry.me/fund"
}
},
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5924,31 +5804,6 @@
"node": ">=8"
}
},
- "node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -7195,9 +7050,9 @@
}
},
"node_modules/depcheck/node_modules/minimatch": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz",
- "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==",
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.7.tgz",
+ "integrity": "sha512-t3SrsBRdssa8F/nFEadAxveFpnbhlbq7FiizzOMqx69w9EbmNEzcKiPkc60udvrOkWsTMm6jmnQP1c5rbdVfSA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7330,16 +7185,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/detect-libc": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
- "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
- "license": "Apache-2.0",
- "optional": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -7412,6 +7257,20 @@
],
"license": "BSD-2-Clause"
},
+ "node_modules/domexception": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
+ "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
@@ -7442,9 +7301,36 @@
}
},
"node_modules/dotenv": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz",
- "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==",
+ "version": "17.2.4",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
+ "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "12.0.3",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
+ "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dotenv": "^16.4.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -7868,26 +7754,25 @@
}
},
"node_modules/eslint": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz",
- "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
+ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.20.1",
- "@eslint/config-helpers": "^0.2.1",
- "@eslint/core": "^0.14.0",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.29.0",
- "@eslint/plugin-kit": "^0.3.1",
+ "@eslint/js": "9.39.3",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
@@ -8357,16 +8242,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/expand-template": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "license": "(MIT OR WTFPL)",
- "optional": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -8582,9 +8457,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
- "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
+ "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
"funding": [
{
"type": "github",
@@ -8593,7 +8468,7 @@
],
"license": "MIT",
"dependencies": {
- "strnum": "^1.1.1"
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8906,13 +8781,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT",
- "optional": true
- },
"node_modules/fs-extra": {
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
@@ -9147,13 +9015,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
- "node_modules/github-from-package": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/glob": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
@@ -9207,16 +9068,37 @@
"tslib": "2"
}
},
- "node_modules/glob/node_modules/minimatch": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
- "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
- "license": "BlueOak-1.0.0",
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "license": "MIT",
"dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
+ "balanced-match": "^4.0.2"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -9528,13 +9410,6 @@
"node": ">=10"
}
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
@@ -9687,9 +9562,9 @@
}
},
"node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
+ "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"peer": true,
"engines": {
@@ -9880,27 +9755,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause",
- "optional": true
- },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -10959,6 +10813,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
@@ -11110,6 +10965,14 @@
"prebuild-install": "^7.0.1"
}
},
+ "node_modules/keytar/node_modules/prebuild-install": {
+ "name": "nop",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nop/-/nop-1.0.0.tgz",
+ "integrity": "sha512-XdkOuXGx0DTwlqb0DWTcDqelgU/F3YyZ+PTRaecpDVpkYskcnh3OeUYKfvjcRQ2D1diTIGxi/a3eHVjW5yPupQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11852,9 +11715,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
+ "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -11907,13 +11770,6 @@
"node": ">=10"
}
},
- "node_modules/mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT",
- "optional": true
- },
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
@@ -12085,13 +11941,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/napi-build-utils": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT",
- "optional": true
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12115,19 +11964,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/node-abi": {
- "version": "3.75.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
- "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
@@ -12175,6 +12011,12 @@
}
}
},
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "license": "MIT"
+ },
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -13305,33 +13147,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/prebuild-install": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "detect-libc": "^2.0.0",
- "expand-template": "^2.0.3",
- "github-from-package": "0.0.0",
- "minimist": "^1.2.3",
- "mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^2.0.0",
- "node-abi": "^3.3.0",
- "pump": "^3.0.0",
- "rc": "^1.2.7",
- "simple-get": "^4.0.0",
- "tar-fs": "^2.0.0",
- "tunnel-agent": "^0.6.0"
- },
- "bin": {
- "prebuild-install": "bin.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14401,9 +14216,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -14642,53 +14457,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/simple-concat": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true
- },
- "node_modules/simple-get": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "decompress-response": "^6.0.0",
- "once": "^1.3.1",
- "simple-concat": "^1.0.0"
- }
- },
"node_modules/simple-git": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
@@ -15195,9 +14963,9 @@
}
},
"node_modules/strnum": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
- "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
@@ -15349,9 +15117,9 @@
}
},
"node_modules/systeminformation": {
- "version": "5.30.2",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz",
- "integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==",
+ "version": "5.31.1",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz",
+ "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==",
"license": "MIT",
"os": [
"darwin",
@@ -15392,9 +15160,9 @@
}
},
"node_modules/table/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15494,43 +15262,6 @@
"node": ">=18"
}
},
- "node_modules/tar-fs": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
- "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/tar-fs/node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC",
- "optional": true
- },
- "node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/teeny-request": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@@ -15604,41 +15335,54 @@
}
},
"node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "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": "^9.0.4"
+ "minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -15888,9 +15632,9 @@
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16004,19 +15748,6 @@
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -16150,15 +15881,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz",
- "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.35.0",
- "@typescript-eslint/parser": "8.35.0",
- "@typescript-eslint/utils": "8.35.0"
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -16168,8 +15900,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
@@ -16578,6 +16310,16 @@
}
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -17424,6 +17166,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -17499,9 +17243,9 @@
}
},
"packages/core/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -17606,6 +17350,12 @@
"node": ">= 4"
}
},
+ "packages/core/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"packages/core/node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
diff --git a/package.json b/package.json
index 7f5bf66348..ec7e272a7e 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,8 @@
"wrap-ansi": "7.0.0"
},
"glob": "^12.0.0",
- "node-domexception": "^1.0.0"
+ "node-domexception": "npm:empty@^0.10.1",
+ "prebuild-install": "npm:nop@1.0.0"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -97,6 +98,7 @@
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
+ "domexception": "^4.0.0",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
@@ -130,6 +132,7 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
+ "node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts
index b0522a945f..e2287a2562 100644
--- a/packages/a2a-server/src/agent/executor.ts
+++ b/packages/a2a-server/src/agent/executor.ts
@@ -29,6 +29,8 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
+ getContextIdFromMetadata,
+ getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (metadata['_contextId'] as string) || sdkTask.contextId;
+ getContextIdFromMetadata(metadata) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const agentSettings = agentSettingsInput || ({} as AgentSettings);
+ const agentSettings: AgentSettings = agentSettingsInput || {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: process.cwd(),
+ };
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (sdkTask?.metadata?.['_contextId'] as string) ||
+ getContextIdFromMetadata(sdkTask?.metadata) ||
uuidv4();
logger.info(
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const agentSettings = userMessage.metadata?.[
- 'coderAgent'
- ] as AgentSettings;
+ const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
try {
wrapper = await this.createTask(
taskId,
diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts
index 39cfe5eb74..81987a780b 100644
--- a/packages/a2a-server/src/agent/task.test.ts
+++ b/packages/a2a-server/src/agent/task.test.ts
@@ -513,7 +513,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
@@ -533,7 +536,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
index 7d9ced1f7a..c91ef72781 100644
--- a/packages/a2a-server/src/agent/task.ts
+++ b/packages/a2a-server/src/agent/task.ts
@@ -13,6 +13,7 @@ import {
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
+ getErrorMessage,
parseAndFormatApiError,
safeLiteralReplace,
DEFAULT_GUI_EDITOR,
@@ -58,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys = T extends T ? keyof T : never;
+type ConfirmationType = ToolCallConfirmationDetails['type'];
+
+const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
+ 'edit',
+ 'exec',
+ 'mcp',
+ 'info',
+ 'ask_user',
+ 'exit_plan_mode',
+] as const;
+
+function isToolCallConfirmationDetails(
+ value: unknown,
+): value is ToolCallConfirmationDetails {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('onConfirm' in value) ||
+ typeof value.onConfirm !== 'function' ||
+ !('type' in value) ||
+ typeof value.type !== 'string'
+ ) {
+ return false;
+ }
+ return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
+}
+
export class Task {
id: string;
contextId: string;
@@ -375,11 +403,10 @@ export class Task {
}
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
- this.pendingToolConfirmationDetails.set(
- tc.request.callId,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- tc.confirmationDetails as ToolCallConfirmationDetails,
- );
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ this.pendingToolConfirmationDetails.set(tc.request.callId, details);
+ }
}
// Only send an update if the status has actually changed.
@@ -411,11 +438,12 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
- (tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
- ToolConfirmationOutcome.ProceedOnce,
- );
- this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ }
}
});
return;
@@ -465,15 +493,13 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys,
>(from: T, ...fields: K[]): Partial {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const ret = {} as Pick;
+ const ret: Partial = {};
for (const field of fields) {
- if (field in from) {
+ if (field in from && from[field] !== undefined) {
ret[field] = from[field];
}
}
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return ret as Partial;
+ return ret;
}
private toolStatusMessage(
@@ -484,8 +510,11 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
- // properties/avoid methods causing circular reference errors)
- const serializableToolCall: Partial = this._pickFields(
+ // properties/avoid methods causing circular reference errors).
+ // Type allows tool to be Partial for serialization.
+ const serializableToolCall: Partial> & {
+ tool?: Partial;
+ } = this._pickFields(
tc,
'request',
'status',
@@ -495,8 +524,7 @@ export class Task {
);
if (tc.tool) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- serializableToolCall.tool = this._pickFields(
+ const toolFields = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -506,7 +534,8 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
- ) as AnyDeclarativeTool;
+ );
+ serializableToolCall.tool = toolFields;
}
messageParts.push({
@@ -529,8 +558,15 @@ export class Task {
old_string: string,
new_string: string,
): Promise {
+ // Validate path to prevent path traversal vulnerabilities
+ const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
+ const pathError = this.config.validatePathAccess(resolvedPath, 'read');
+ if (pathError) {
+ throw new Error(`Path validation failed: ${pathError}`);
+ }
+
try {
- const currentContent = await fs.readFile(file_path, 'utf8');
+ const currentContent = await fs.readFile(resolvedPath, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -624,15 +660,32 @@ export class Task {
request.args['old_string'] &&
request.args['new_string']
) {
- const newContent = await this.getProposedContent(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['file_path'] as string,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['old_string'] as string,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['new_string'] as string,
- );
- return { ...request, args: { ...request.args, newContent } };
+ const filePath = request.args['file_path'];
+ const oldString = request.args['old_string'];
+ const newString = request.args['new_string'];
+ if (
+ typeof filePath === 'string' &&
+ typeof oldString === 'string' &&
+ typeof newString === 'string'
+ ) {
+ // Resolve and validate path to prevent path traversal (user-controlled file_path).
+ const resolvedPath = path.resolve(
+ this.config.getTargetDir(),
+ filePath,
+ );
+ const pathError = this.config.validatePathAccess(
+ resolvedPath,
+ 'read',
+ );
+ if (!pathError) {
+ const newContent = await this.getProposedContent(
+ resolvedPath,
+ oldString,
+ newString,
+ );
+ return { ...request, args: { ...request.args, newContent } };
+ }
+ }
}
return request;
}),
@@ -724,19 +777,27 @@ export class Task {
break;
case GeminiEventType.Error:
default: {
- // Block scope for lexical declaration
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
- const errorMessage =
- errorEvent.value?.error?.message ?? 'Unknown error from LLM stream';
+ // Use type guard instead of unsafe type assertion
+ let errorEvent: ServerGeminiErrorEvent | undefined;
+ if (
+ event.type === GeminiEventType.Error &&
+ event.value &&
+ typeof event.value === 'object' &&
+ 'error' in event.value
+ ) {
+ errorEvent = event;
+ }
+ const errorMessage = errorEvent?.value?.error
+ ? getErrorMessage(errorEvent.value.error)
+ : 'Unknown error from LLM stream';
logger.error(
'[Task] Received error event from LLM stream:',
errorMessage,
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
- if (errorEvent.value) {
- errMessage = parseAndFormatApiError(errorEvent.value);
+ if (errorEvent?.value?.error) {
+ errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
this.setTaskStateAndPublishUpdate(
@@ -812,12 +873,11 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
- const payload = part.data['newContent']
- ? ({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- newContent: part.data['newContent'] as string,
- } as ToolConfirmationPayload)
- : undefined;
+ const newContent = part.data['newContent'];
+ const payload =
+ typeof newContent === 'string'
+ ? ({ newContent } as ToolConfirmationPayload)
+ : undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts
index 1c6bdc38fb..e68ebc4431 100644
--- a/packages/a2a-server/src/config/config.test.ts
+++ b/packages/a2a-server/src/config/config.test.ts
@@ -267,4 +267,47 @@ describe('loadConfig', () => {
customIgnoreFilePaths: [testPath],
});
});
+
+ describe('tool configuration', () => {
+ it('should pass V1 allowedTools to Config properly', async () => {
+ const settings: Settings = {
+ allowedTools: ['shell', 'edit'],
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['shell', 'edit'],
+ }),
+ );
+ });
+
+ it('should pass V2 tools.allowed to Config properly', async () => {
+ const settings: Settings = {
+ tools: {
+ allowed: ['shell', 'fetch'],
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['shell', 'fetch'],
+ }),
+ );
+ });
+
+ it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
+ const settings: Settings = {
+ allowedTools: ['v1-tool'],
+ tools: {
+ allowed: ['v2-tool'],
+ },
+ };
+ await loadConfig(settings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedTools: ['v1-tool'],
+ }),
+ );
+ });
+ });
});
diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts
index eb92e55f36..6a27bca4d5 100644
--- a/packages/a2a-server/src/config/config.ts
+++ b/packages/a2a-server/src/config/config.ts
@@ -68,8 +68,9 @@ export async function loadConfig(
debugMode: process.env['DEBUG'] === 'true' || false,
question: '', // Not used in server mode directly like CLI
- coreTools: settings.coreTools || undefined,
- excludeTools: settings.excludeTools || undefined,
+ coreTools: settings.coreTools || settings.tools?.core || undefined,
+ excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
+ allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode:
process.env['GEMINI_YOLO_MODE'] === 'true'
diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts
index a2b11d0886..b3c44cc177 100644
--- a/packages/a2a-server/src/config/settings.ts
+++ b/packages/a2a-server/src/config/settings.ts
@@ -27,6 +27,12 @@ export interface Settings {
mcpServers?: Record;
coreTools?: string[];
excludeTools?: string[];
+ allowedTools?: string[];
+ tools?: {
+ allowed?: string[];
+ exclude?: string[];
+ core?: string[];
+ };
telemetry?: TelemetrySettings;
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
diff --git a/packages/a2a-server/src/http/server.ts b/packages/a2a-server/src/http/server.ts
index c22be49331..1bfb29c081 100644
--- a/packages/a2a-server/src/http/server.ts
+++ b/packages/a2a-server/src/http/server.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env node
+#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts
index 0ed6a67994..bce233c9dd 100644
--- a/packages/a2a-server/src/types.ts
+++ b/packages/a2a-server/src/types.ts
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
+function isAgentSettings(value: unknown): value is AgentSettings {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'kind' in value &&
+ value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
+ 'workspacePath' in value &&
+ typeof value.workspacePath === 'string'
+ );
+}
+
+function isPersistedStateMetadata(
+ value: unknown,
+): value is PersistedStateMetadata {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ '_agentSettings' in value &&
+ '_taskState' in value &&
+ isAgentSettings(value._agentSettings)
+ );
+}
+
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
+ const state = metadata?.[METADATA_KEY];
+ if (isPersistedStateMetadata(state)) {
+ return state;
+ }
+ return undefined;
+}
+
+export function getContextIdFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): string | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const contextId = metadata['_contextId'];
+ return typeof contextId === 'string' ? contextId : undefined;
+}
+
+export function getAgentSettingsFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): AgentSettings | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const coderAgent = metadata['coderAgent'];
+ if (isAgentSettings(coderAgent)) {
+ return coderAgent;
+ }
+ return undefined;
}
export function setPersistedState(
diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts
index 86d0d4a4bd..9cb0657c7a 100644
--- a/packages/a2a-server/src/utils/testing_utils.ts
+++ b/packages/a2a-server/src/utils/testing_utils.ts
@@ -71,6 +71,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
+ validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
index 29a83b2337..5444fe1b74 100644
--- a/packages/cli/index.ts
+++ b/packages/cli/index.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env node
+#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
});
main().catch(async (error) => {
- await runExitCleanup();
+ // Set a timeout to force exit if cleanup hangs
+ const cleanupTimeout = setTimeout(() => {
+ writeToStderr('Cleanup timed out, forcing exit...\n');
+ process.exit(1);
+ }, 5000);
+
+ try {
+ await runExitCleanup();
+ } catch (cleanupError) {
+ writeToStderr(
+ `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
+ );
+ } finally {
+ clearTimeout(cleanupTimeout);
+ }
if (error instanceof FatalError) {
let errorMessage = error.message;
@@ -46,6 +60,7 @@ main().catch(async (error) => {
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
+
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 809b31cd82..75812e4442 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -2016,6 +2016,40 @@ describe('loadCliConfig useRipgrep', () => {
});
});
+describe('loadCliConfig directWebFetch', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
+ vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
+ vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('should be false by default when directWebFetch is not set in settings', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments(createTestMergedSettings());
+ const settings = createTestMergedSettings();
+ const config = await loadCliConfig(settings, 'test-session', argv);
+ expect(config.getDirectWebFetch()).toBe(false);
+ });
+
+ it('should be true when directWebFetch is set to true in settings', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments(createTestMergedSettings());
+ const settings = createTestMergedSettings({
+ experimental: {
+ directWebFetch: true,
+ },
+ });
+ const config = await loadCliConfig(settings, 'test-session', argv);
+ expect(config.getDirectWebFetch()).toBe(true);
+ });
+});
+
describe('screenReader configuration', () => {
beforeEach(() => {
vi.resetAllMocks();
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 38ab62ac22..3e0fd4b913 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -826,7 +826,8 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
- planSettings: settings.general.plan,
+ directWebFetch: settings.experimental?.directWebFetch,
+ planSettings: settings.general?.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -877,6 +878,7 @@ export async function loadCliConfig(
agents: refreshedSettings.merged.agents,
};
},
+ enableConseca: settings.security?.enableConseca,
});
}
diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts
index ef066977a1..bdbbdb2401 100644
--- a/packages/cli/src/config/extensions/extensionSettings.test.ts
+++ b/packages/cli/src/config/extensions/extensionSettings.test.ts
@@ -590,6 +590,29 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});
+
+ it('should ignore .env if it is a directory', async () => {
+ const workspaceEnvPath = path.join(
+ tempWorkspaceDir,
+ EXTENSION_SETTINGS_FILENAME,
+ );
+ fs.mkdirSync(workspaceEnvPath);
+ const workspaceKeychain = new KeychainTokenStorage(
+ `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
+ );
+ await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
+
+ const contents = await getScopedEnvContents(
+ config,
+ extensionId,
+ ExtensionSettingScope.WORKSPACE,
+ tempWorkspaceDir,
+ );
+
+ expect(contents).toEqual({
+ SENSITIVE_VAR: 'workspace-secret',
+ });
+ });
});
describe('getEnvContents (merged)', () => {
@@ -696,6 +719,26 @@ describe('extensionSettings', () => {
expect(actualContent).toContain('VAR1=new-workspace-value');
});
+ it('should throw an error when trying to write to a workspace with a .env directory', async () => {
+ const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
+ fs.mkdirSync(workspaceEnvPath);
+
+ mockRequestSetting.mockResolvedValue('new-workspace-value');
+
+ await expect(
+ updateSetting(
+ config,
+ '12345',
+ 'VAR1',
+ mockRequestSetting,
+ ExtensionSettingScope.WORKSPACE,
+ tempWorkspaceDir,
+ ),
+ ).rejects.toThrow(
+ /Cannot write extension settings to .* because it is a directory./,
+ );
+ });
+
it('should update a sensitive setting in USER scope', async () => {
mockRequestSetting.mockResolvedValue('new-value2');
diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts
index 06e4f49db4..700d854e20 100644
--- a/packages/cli/src/config/extensions/extensionSettings.ts
+++ b/packages/cli/src/config/extensions/extensionSettings.ts
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
const envContent = formatEnvContent(nonSensitiveSettings);
+ if (fsSync.existsSync(envFilePath)) {
+ const stat = fsSync.statSync(envFilePath);
+ if (stat.isDirectory()) {
+ throw new Error(
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
+ );
+ }
+ }
+
await fs.writeFile(envFilePath, envContent);
}
@@ -173,8 +182,11 @@ export async function getScopedEnvContents(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record = {};
if (fsSync.existsSync(envFilePath)) {
- const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
- customEnv = dotenv.parse(envFile);
+ const stat = fsSync.statSync(envFilePath);
+ if (!stat.isDirectory()) {
+ const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
+ customEnv = dotenv.parse(envFile);
+ }
}
if (extensionConfig.settings) {
@@ -260,6 +272,12 @@ export async function updateSetting(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let envContent = '';
if (fsSync.existsSync(envFilePath)) {
+ const stat = fsSync.statSync(envFilePath);
+ if (stat.isDirectory()) {
+ throw new Error(
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
+ );
+ }
envContent = await fs.readFile(envFilePath, 'utf-8');
}
@@ -324,7 +342,10 @@ async function clearSettings(
keychain: KeychainTokenStorage,
) {
if (fsSync.existsSync(envFilePath)) {
- await fs.writeFile(envFilePath, '');
+ const stat = fsSync.statSync(envFilePath);
+ if (!stat.isDirectory()) {
+ await fs.writeFile(envFilePath, '');
+ }
}
if (!(await keychain.isAvailable())) {
return;
diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts
index dbc7f6a415..1d7573337e 100644
--- a/packages/cli/src/config/policy-engine.integration.test.ts
+++ b/packages/cli/src/config/policy-engine.integration.test.ts
@@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
+ it('should handle global MCP wildcard (*) in settings', async () => {
+ const settings: Settings = {
+ mcp: {
+ allowed: ['*'],
+ },
+ };
+
+ const config = await createPolicyEngineConfig(
+ settings,
+ ApprovalMode.DEFAULT,
+ );
+ const engine = new PolicyEngine(config);
+
+ // ANY tool with a server name should be allowed
+ expect(
+ (await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
+ .decision,
+ ).toBe(PolicyDecision.ALLOW);
+ expect(
+ (await engine.check({ name: 'another-server__tool' }, 'another-server'))
+ .decision,
+ ).toBe(PolicyDecision.ALLOW);
+
+ // Built-in tools should NOT be allowed by the MCP wildcard
+ expect(
+ (await engine.check({ name: 'run_shell_command' }, undefined)).decision,
+ ).toBe(PolicyDecision.ASK_USER);
+ });
+
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
const settings: Settings = {
mcp: {
@@ -323,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
+ it('should correctly match tool annotations', async () => {
+ const settings: Settings = {};
+
+ const config = await createPolicyEngineConfig(
+ settings,
+ ApprovalMode.DEFAULT,
+ );
+
+ // Add a manual rule with annotations to the config
+ config.rules = config.rules || [];
+ config.rules.push({
+ toolAnnotations: { readOnlyHint: true },
+ decision: PolicyDecision.ALLOW,
+ priority: 10,
+ });
+
+ const engine = new PolicyEngine(config);
+
+ // A tool with readOnlyHint=true should be ALLOWED
+ const roCall = { name: 'some_tool', args: {} };
+ const roMeta = { readOnlyHint: true };
+ expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
+ PolicyDecision.ALLOW,
+ );
+
+ // A tool without the hint (or with false) should follow default decision (ASK_USER)
+ const rwMeta = { readOnlyHint: false };
+ expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
+ PolicyDecision.ASK_USER,
+ );
+ });
+
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
@@ -339,6 +400,8 @@ describe('Policy Engine Integration Tests', () => {
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
+ 'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
+ 'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
];
for (const file_path of validPaths) {
@@ -364,7 +427,8 @@ describe('Policy Engine Integration Tests', () => {
const invalidPaths = [
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
- '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
+ '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
+ 'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts
index a0e687388d..1a773d56a7 100644
--- a/packages/cli/src/config/policy.test.ts
+++ b/packages/cli/src/config/policy.test.ts
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
expect.stringContaining('Automatically accepting and loading'),
);
});
+
+ it('should not return workspace policies if cwd is the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Run from HOME directory (tempDir is mocked as HOME in beforeEach)
+ const result = await resolveWorkspacePolicyState({
+ cwd: tempDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ });
+
+ it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Create a symlink to the home directory
+ const symlinkDir = path.join(
+ os.tmpdir(),
+ `gemini-cli-symlink-${Date.now()}`,
+ );
+ fs.symlinkSync(tempDir, symlinkDir, 'dir');
+
+ try {
+ // Run from symlink to HOME directory
+ const result = await resolveWorkspacePolicyState({
+ cwd: symlinkDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ } finally {
+ // Clean up symlink
+ fs.unlinkSync(symlinkDir);
+ }
+ });
});
diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts
index ef6164efb7..3b85d0b4b6 100644
--- a/packages/cli/src/config/policy.ts
+++ b/packages/cli/src/config/policy.ts
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
| undefined;
if (trustedFolder) {
- const potentialWorkspacePoliciesDir = new Storage(
- cwd,
- ).getWorkspacePoliciesDir();
+ const storage = new Storage(cwd);
+
+ // If we are in the home directory (or rather, our target Gemini dir is the global one),
+ // don't treat it as a workspace to avoid loading global policies twice.
+ if (storage.isWorkspaceHomeDir()) {
+ return { workspacePoliciesDir: undefined };
+ }
+
+ const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 7b341b3ee0..6b2f18bb58 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -79,6 +79,7 @@ import {
import {
FatalConfigError,
GEMINI_DIR,
+ Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal();
const os = await import('node:os');
+ const pathMod = await import('node:path');
+ const fsMod = await import('node:fs');
+
+ // Helper to resolve paths using the test's mocked environment
+ const testResolve = (p: string | undefined) => {
+ if (!p) return '';
+ try {
+ // Use the mocked fs.realpathSync if available, otherwise fallback
+ return fsMod.realpathSync(pathMod.resolve(p));
+ } catch {
+ return pathMod.resolve(p);
+ }
+ };
+
+ // Create a smarter mock for isWorkspaceHomeDir
+ vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
+ function (this: Storage) {
+ const target = testResolve(pathMod.dirname(this.getGeminiDir()));
+ // Pick up the mocked home directory specifically from the 'os' mock
+ const home = testResolve(os.homedir());
+ return actual.normalizePath(target) === actual.normalizePath(home);
+ },
+ );
+
return {
...actual,
coreEvents: mockCoreEvents,
@@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => {
return pStr;
});
+ // Force the storage check to return true for this specific test
+ const isWorkspaceHomeDirSpy = vi
+ .spyOn(Storage.prototype, 'isWorkspaceHomeDir')
+ .mockReturnValue(true);
+
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
- const settings = loadSettings(mockSymlinkDir);
+ try {
+ const settings = loadSettings(mockSymlinkDir);
- // Verify that even though the file exists, it was NOT loaded because realpath matched home
- expect(fs.readFileSync).not.toHaveBeenCalledWith(
- mockWorkspaceSettingsPath,
- 'utf-8',
- );
- expect(settings.workspace.settings).toEqual({});
+ // Verify that even though the file exists, it was NOT loaded because realpath matched home
+ expect(fs.readFileSync).not.toHaveBeenCalledWith(
+ mockWorkspaceSettingsPath,
+ 'utf-8',
+ );
+ expect(settings.workspace.settings).toEqual({});
+ } finally {
+ isWorkspaceHomeDirSpy.mockRestore();
+ }
});
});
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 2f6f2f7450..c3f7c447eb 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -637,24 +637,8 @@ export function loadSettings(
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
- // Resolve paths to their canonical representation to handle symlinks
- const resolvedWorkspaceDir = path.resolve(workspaceDir);
- const resolvedHomeDir = path.resolve(homedir());
-
- let realWorkspaceDir = resolvedWorkspaceDir;
- try {
- // fs.realpathSync gets the "true" path, resolving any symlinks
- realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
- } catch (_e) {
- // This is okay. The path might not exist yet, and that's a valid state.
- }
-
- // We expect homedir to always exist and be resolvable.
- const realHomeDir = fs.realpathSync(resolvedHomeDir);
-
- const workspaceSettingsPath = new Storage(
- workspaceDir,
- ).getWorkspaceSettingsPath();
+ const storage = new Storage(workspaceDir);
+ const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
@@ -712,7 +696,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
- if (realWorkspaceDir !== realHomeDir) {
+ if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -800,11 +784,11 @@ export function loadSettings(
readOnly: false,
},
{
- path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
+ path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
- readOnly: realWorkspaceDir === realHomeDir,
+ readOnly: storage.isWorkspaceHomeDir(),
},
isTrusted,
settingsErrors,
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index cbe2df5f30..ee60731b5c 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
+ maxAttempts: {
+ type: 'number',
+ label: 'Max Chat Model Attempts',
+ category: 'General',
+ requiresRestart: false,
+ default: 10,
+ description:
+ 'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
+ showInDialog: true,
+ },
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -964,6 +974,60 @@ const SETTINGS_SCHEMA = {
ref: 'AgentOverride',
},
},
+ browser: {
+ type: 'object',
+ label: 'Browser Agent',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: {},
+ description: 'Settings specific to the browser agent.',
+ showInDialog: false,
+ properties: {
+ sessionMode: {
+ type: 'enum',
+ label: 'Browser Session Mode',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: 'persistent',
+ description:
+ "Session mode: 'persistent', 'isolated', or 'existing'.",
+ showInDialog: false,
+ options: [
+ { value: 'persistent', label: 'Persistent' },
+ { value: 'isolated', label: 'Isolated' },
+ { value: 'existing', label: 'Existing' },
+ ],
+ },
+ headless: {
+ type: 'boolean',
+ label: 'Browser Headless',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: false,
+ description: 'Run browser in headless mode.',
+ showInDialog: false,
+ },
+ profilePath: {
+ type: 'string',
+ label: 'Browser Profile Path',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description:
+ 'Path to browser profile directory for session persistence.',
+ showInDialog: false,
+ },
+ visualModel: {
+ type: 'string',
+ label: 'Browser Visual Model',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description: 'Model override for the visual agent.',
+ showInDialog: false,
+ },
+ },
+ },
},
},
@@ -1483,6 +1547,16 @@ const SETTINGS_SCHEMA = {
},
},
},
+ enableConseca: {
+ type: 'boolean',
+ label: 'Enable Context-Aware Security',
+ category: 'Security',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
+ showInDialog: true,
+ },
},
},
@@ -1693,6 +1767,16 @@ const SETTINGS_SCHEMA = {
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
+ directWebFetch: {
+ type: 'boolean',
+ label: 'Direct Web Fetch',
+ category: 'Experimental',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable web fetch behavior that bypasses LLM summarization.',
+ showInDialog: true,
+ },
},
},
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index a13d6e2558..73ec9af2d3 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -393,9 +393,11 @@ export const render = (
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: RenderMetrics) => {
- if (isInkRenderMetrics(metrics)) {
- stdout.onRender(metrics.staticOutput ?? '', metrics.output);
- }
+ const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
+ const staticOutput = isInkRenderMetrics(metrics)
+ ? (metrics.staticOutput ?? '')
+ : '';
+ stdout.onRender(staticOutput, output);
},
});
});
diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts
index 0825527cf5..c602f587fb 100644
--- a/packages/cli/src/ui/colors.ts
+++ b/packages/cli/src/ui/colors.ts
@@ -53,6 +53,12 @@ export const Colors: ColorsTheme = {
get DarkGray() {
return themeManager.getColors().DarkGray;
},
+ get InputBackground() {
+ return themeManager.getColors().InputBackground;
+ },
+ get MessageBackground() {
+ return themeManager.getColors().MessageBackground;
+ },
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
},
diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts
index 4f224201c9..554d5cd53d 100644
--- a/packages/cli/src/ui/commands/policiesCommand.test.ts
+++ b/packages/cli/src/ui/commands/policiesCommand.test.ts
@@ -9,7 +9,11 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
-import { type Config, PolicyDecision } from '@google/gemini-cli-core';
+import {
+ type Config,
+ PolicyDecision,
+ ApprovalMode,
+} from '@google/gemini-cli-core';
describe('policiesCommand', () => {
let mockContext: ReturnType;
@@ -106,6 +110,7 @@ describe('policiesCommand', () => {
expect(content).toContain(
'### Yolo Mode Policies (combined with normal mode policies)',
);
+ expect(content).toContain('### Plan Mode Policies');
expect(content).toContain(
'**DENY** tool: `dangerousTool` [Priority: 10]',
);
@@ -114,5 +119,45 @@ describe('policiesCommand', () => {
);
expect(content).toContain('**ASK_USER** all tools');
});
+
+ it('should show plan-only rules in plan mode section', async () => {
+ const mockRules = [
+ {
+ decision: PolicyDecision.ALLOW,
+ toolName: 'glob',
+ priority: 70,
+ modes: [ApprovalMode.PLAN],
+ },
+ {
+ decision: PolicyDecision.DENY,
+ priority: 60,
+ modes: [ApprovalMode.PLAN],
+ },
+ {
+ decision: PolicyDecision.ALLOW,
+ toolName: 'shell',
+ priority: 50,
+ },
+ ];
+ const mockPolicyEngine = {
+ getRules: vi.fn().mockReturnValue(mockRules),
+ };
+ mockContext.services.config = {
+ getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
+ } as unknown as Config;
+
+ const listCommand = policiesCommand.subCommands![0];
+ await listCommand.action!(mockContext, '');
+
+ const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
+ const content = (call[0] as { text: string }).text;
+
+ // Plan-only rules appear under Plan Mode section
+ expect(content).toContain('### Plan Mode Policies');
+ // glob ALLOW is plan-only, should appear in plan section
+ expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]');
+ // shell ALLOW has no modes (applies to all), appears in normal section
+ expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
+ });
});
});
diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts
index ebfd57abaf..f4bd13de28 100644
--- a/packages/cli/src/ui/commands/policiesCommand.ts
+++ b/packages/cli/src/ui/commands/policiesCommand.ts
@@ -12,6 +12,7 @@ interface CategorizedRules {
normal: PolicyRule[];
autoEdit: PolicyRule[];
yolo: PolicyRule[];
+ plan: PolicyRule[];
}
const categorizeRulesByMode = (
@@ -21,6 +22,7 @@ const categorizeRulesByMode = (
normal: [],
autoEdit: [],
yolo: [],
+ plan: [],
};
const ALL_MODES = Object.values(ApprovalMode);
rules.forEach((rule) => {
@@ -29,6 +31,7 @@ const categorizeRulesByMode = (
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
+ if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule);
});
return result;
};
@@ -82,6 +85,9 @@ const listPoliciesCommand: SlashCommand = {
const uniqueYolo = categorized.yolo.filter(
(rule) => !normalRulesSet.has(rule),
);
+ const uniquePlan = categorized.plan.filter(
+ (rule) => !normalRulesSet.has(rule),
+ );
let content = '**Active Policies**\n\n';
content += formatSection('Normal Mode Policies', categorized.normal);
@@ -93,6 +99,7 @@ const listPoliciesCommand: SlashCommand = {
'Yolo Mode Policies (combined with normal mode policies)',
uniqueYolo,
);
+ content += formatSection('Plan Mode Policies', uniquePlan);
context.ui.addItem(
{
diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx
index 59c04e9938..4d59bf14aa 100644
--- a/packages/cli/src/ui/components/Header.test.tsx
+++ b/packages/cli/src/ui/components/Header.test.tsx
@@ -96,6 +96,8 @@ describe('', () => {
},
background: {
primary: '',
+ message: '',
+ input: '',
diff: { added: '', removed: '' },
},
border: {
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 689df105ca..ad84dd27f6 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -56,10 +56,6 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
-import {
- DEFAULT_BACKGROUND_OPACITY,
- DEFAULT_INPUT_BACKGROUND_OPACITY,
-} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -226,7 +222,6 @@ export const InputPrompt: React.FC = ({
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
- hintMode,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -1422,14 +1417,8 @@ export const InputPrompt: React.FC = ({
/>
) : null}
{
return (
-
+
{itemsForDisplay.map((item, index) => (
Initial Rendering > should render settings list with v
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false* โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ > Apply To โ
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging false โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
โ Plan Directory undefined โ
โ The directory where planning artifacts are stored. If not specified, defaults tโฆ โ
โ โ
+โ Max Chat Model Attempts 10 โ
+โ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. โ
+โ โ
โ Debug Keystroke Logging true* โ
โ Enable debug logging of keystrokes to the console. โ
โ โ
โ Enable Session Cleanup false โ
โ Enable automatic session cleanup โ
โ โ
-โ Keep chat history undefined โ
-โ Automatically delete chats older than this time period (e.g., "30d", "7d", "24hโฆ โ
-โ โ
โ โผ โ
โ โ
โ Apply To โ
diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
index 817d4ceeec..70d2cba48d 100644
--- a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
@@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
-"โโ Shortcuts (for more, see /help) โโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -16,7 +17,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
-"โโ Shortcuts (for more, see /help) โโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -31,7 +33,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
-"โโ Shortcuts (for more, see /help) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -40,7 +43,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
-"โโ Shortcuts (for more, see /help) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 54abbc09d3..8e760b28e7 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC = ({
borderColor,
borderDimColor,
+
isExpandable,
+
+ originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
+ originalRequestName={originalRequestName}
/>
{
expect(output).toMatchSnapshot();
unmount();
});
+
+ it('should show MCP tool details expand hint for MCP confirmations', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {
+ url: 'https://www.google.co.jp',
+ },
+ toolDescription: 'Navigates browser to a URL.',
+ toolParameterSchema: {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ description: 'Destination URL',
+ },
+ },
+ required: ['url'],
+ },
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('https://www.google.co.jp');
+ expect(output).not.toContain('Navigates browser to a URL.');
+ unmount();
+ });
+
+ it('should omit empty MCP invocation arguments from details', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {},
+ toolDescription: 'No arguments required.',
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('Invocation Arguments:');
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index c4e73b73f6..9a49e2aa5a 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { useMemo, useCallback } from 'react';
+import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
+ const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
+ callId: string;
+ expanded: boolean;
+ }>({
+ callId,
+ expanded: false,
+ });
+ const isMcpToolDetailsExpanded =
+ mcpDetailsExpansionState.callId === callId
+ ? mcpDetailsExpansionState.expanded
+ : false;
const settings = useSettings();
const allowPermanentApproval =
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
+ const mcpToolDetailsText = useMemo(() => {
+ if (confirmationDetails.type !== 'mcp') {
+ return null;
+ }
+
+ const detailsLines: string[] = [];
+ const hasNonEmptyToolArgs =
+ confirmationDetails.toolArgs !== undefined &&
+ !(
+ typeof confirmationDetails.toolArgs === 'object' &&
+ confirmationDetails.toolArgs !== null &&
+ Object.keys(confirmationDetails.toolArgs).length === 0
+ );
+ if (hasNonEmptyToolArgs) {
+ let argsText: string;
+ try {
+ argsText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolArgs, null, 2),
+ );
+ } catch {
+ argsText = '[unserializable arguments]';
+ }
+ detailsLines.push('Invocation Arguments:');
+ detailsLines.push(argsText);
+ }
+
+ const description = confirmationDetails.toolDescription?.trim();
+ if (description) {
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Description:');
+ detailsLines.push(stripUnsafeCharacters(description));
+ }
+
+ if (confirmationDetails.toolParameterSchema !== undefined) {
+ let schemaText: string;
+ try {
+ schemaText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
+ );
+ } catch {
+ schemaText = '[unserializable schema]';
+ }
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Input Schema:');
+ detailsLines.push(schemaText);
+ }
+
+ if (detailsLines.length === 0) {
+ return null;
+ }
+
+ return detailsLines.join('\n');
+ }, [confirmationDetails]);
+
+ const hasMcpToolDetails = !!mcpToolDetailsText;
+ const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
+
useKeypress(
(key) => {
if (!isFocused) return false;
+ if (
+ confirmationDetails.type === 'mcp' &&
+ hasMcpToolDetails &&
+ keyMatchers[Command.SHOW_MORE_LINES](key)
+ ) {
+ setMcpDetailsExpansionState({
+ callId,
+ expanded: !isMcpToolDetailsExpanded,
+ });
+ return true;
+ }
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
- { isActive: isFocused },
+ { isActive: isFocused, priority: true },
);
const handleSelect = useCallback(
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
-
- MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
-
-
- Tool: {sanitizeForDisplay(mcpProps.toolName)}
-
+ <>
+
+ MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
+
+
+ Tool: {sanitizeForDisplay(mcpProps.toolName)}
+
+ >
+ {hasMcpToolDetails && (
+
+ MCP Tool Details:
+ {isMcpToolDetailsExpanded ? (
+ <>
+
+ (press {expandDetailsHintKey} to collapse MCP tool details)
+
+ {mcpToolDetailsText}
+ >
+ ) : (
+
+ (press {expandDetailsHintKey} to expand MCP tool details)
+
+ )}
+
+ )}
);
}
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
+ isMcpToolDetailsExpanded,
+ hasMcpToolDetails,
+ mcpToolDetailsText,
+ expandDetailsHintKey,
]);
+ const bodyOverflowDirection: 'top' | 'bottom' =
+ confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
+ ? 'bottom'
+ : 'top';
+
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
{bodyContent}
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index 947955ab53..df4354b1c4 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -375,20 +375,25 @@ describe('', () => {
unmount();
});
- it('renders progress information appended to description for executing tools', async () => {
+ it('renders McpProgressIndicator with percentage and message for executing tools', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
,
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain(
- 'A tool for testing (Working on it... - 42%)',
- );
+ const output = lastFrame();
+ expect(output).toContain('42%');
+ expect(output).toContain('Working on it...');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('A tool for testing (Working on it... - 42%)');
+ expect(output).toMatchSnapshot();
unmount();
});
@@ -397,12 +402,37 @@ describe('', () => {
,
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain('A tool for testing (75%)');
+ const output = lastFrame();
+ expect(output).toContain('75%');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('A tool for testing (75%)');
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders indeterminate progress when total is missing', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithContext(
+ ,
+ StreamingState.Responding,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('7');
+ expect(output).toContain('\u2588');
+ expect(output).toContain('\u2591');
+ expect(output).not.toContain('%');
+ expect(output).toMatchSnapshot();
unmount();
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 557e0bd857..8a3e2e2c09 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -13,6 +13,7 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
+ McpProgressIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
@@ -20,7 +21,7 @@ import {
useFocusHint,
FocusHint,
} from './ToolShared.js';
-import { type Config } from '@google/gemini-cli-core';
+import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
export type { TextEmphasis };
@@ -56,7 +57,9 @@ export const ToolMessage: React.FC = ({
ptyId,
config,
progressMessage,
- progressPercent,
+ originalRequestName,
+ progress,
+ progressTotal,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -91,8 +94,7 @@ export const ToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
- progressMessage={progressMessage}
- progressPercent={progressPercent}
+ originalRequestName={originalRequestName}
/>
= ({
paddingX={1}
flexDirection="column"
>
+ {status === CoreToolCallStatus.Executing && progress !== undefined && (
+
+ )}
({
+ GeminiRespondingSpinner: () => MockSpinner,
+}));
+
+describe('McpProgressIndicator', () => {
+ it('renders determinate progress at 50%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('50%');
+ });
+
+ it('renders complete progress at 100%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('100%');
+ });
+
+ it('renders indeterminate progress with raw count', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('7');
+ expect(output).not.toContain('%');
+ });
+
+ it('renders progress with a message', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ expect(output).toContain('Downloading...');
+ });
+
+ it('clamps progress exceeding total to 100%', async () => {
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('100%');
+ expect(output).not.toContain('150%');
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index fc1dc6e45a..4831e07279 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -187,8 +187,7 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
- progressMessage?: string;
- progressPercent?: number;
+ originalRequestName?: string;
};
export const ToolInfo: React.FC = ({
@@ -196,8 +195,7 @@ export const ToolInfo: React.FC = ({
description,
status: coreStatus,
emphasis,
- progressMessage,
- progressPercent,
+ originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo(() => {
@@ -218,34 +216,22 @@ export const ToolInfo: React.FC = ({
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser = isCompletedAskUserTool(name, status);
- let displayDescription = description;
- if (status === ToolCallStatus.Executing) {
- const parts: string[] = [];
- if (progressMessage) {
- parts.push(progressMessage);
- }
- if (progressPercent !== undefined) {
- parts.push(`${Math.round(progressPercent)}%`);
- }
-
- if (parts.length > 0) {
- const progressInfo = parts.join(' - ');
- displayDescription = description
- ? `${description} (${progressInfo})`
- : progressInfo;
- }
- }
-
return (
{name}
+ {originalRequestName && originalRequestName !== name && (
+
+ {' '}
+ (redirection from {originalRequestName})
+
+ )}
{!isCompletedAskUser && (
<>
{' '}
- {displayDescription}
+ {description}
>
)}
@@ -253,6 +239,54 @@ export const ToolInfo: React.FC = ({
);
};
+export interface McpProgressIndicatorProps {
+ progress: number;
+ total?: number;
+ message?: string;
+ barWidth: number;
+}
+
+export const McpProgressIndicator: React.FC = ({
+ progress,
+ total,
+ message,
+ barWidth,
+}) => {
+ const percentage =
+ total && total > 0
+ ? Math.min(100, Math.round((progress / total) * 100))
+ : null;
+
+ let rawFilled: number;
+ if (total && total > 0) {
+ rawFilled = Math.round((progress / total) * barWidth);
+ } else {
+ rawFilled = Math.floor(progress) % (barWidth + 1);
+ }
+
+ const filled = Math.max(
+ 0,
+ Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
+ );
+ const empty = Math.max(0, barWidth - filled);
+ const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
+
+ return (
+
+
+
+ {progressBar} {percentage !== null ? `${percentage}%` : `${progress}`}
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+};
+
export const TrailingIndicator: React.FC = () => (
{' '}
diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx
index ab45db7cf0..6453ab94c1 100644
--- a/packages/cli/src/ui/components/messages/UserMessage.tsx
+++ b/packages/cli/src/ui/components/messages/UserMessage.tsx
@@ -15,7 +15,6 @@ import {
calculateTransformedLine,
} from '../shared/text-buffer.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
-import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps {
@@ -52,8 +51,8 @@ export const UserMessage: React.FC = ({ text, width }) => {
return (
= ({
return (
> renders DiffRenderer for diff results 1`] = `
"
`;
+exports[` > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ MockRespondingSpinnertest-tool A tool for testing โ
+โ โ
+โ โโโโโโโโโโโโโโโโโโโโ 42% โ
+โ Working on it... โ
+โ Test result โ
+"
+`;
+
exports[` > renders basic tool information 1`] = `
"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ โ test-tool A tool for testing โ
@@ -115,3 +125,21 @@ exports[` > renders emphasis correctly 2`] = `
โ Test result โ
"
`;
+
+exports[` > renders indeterminate progress when total is missing 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ MockRespondingSpinnertest-tool A tool for testing โ
+โ โ
+โ โโโโโโโโโโโโโโโโโโโโ 7 โ
+โ Test result โ
+"
+`;
+
+exports[` > renders only percentage when progressMessage is missing 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ MockRespondingSpinnertest-tool A tool for testing โ
+โ โ
+โ โโโโโโโโโโโโโโโโโโโโ 75% โ
+โ Test result โ
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap
new file mode 100644
index 0000000000..b812b4a7c6
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolShared.test.tsx.snap
@@ -0,0 +1,22 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`McpProgressIndicator > renders complete progress at 100% 1`] = `
+"โโโโโโโโโโโโโโโโโโโโ 100%
+"
+`;
+
+exports[`McpProgressIndicator > renders determinate progress at 50% 1`] = `
+"โโโโโโโโโโโโโโโโโโโโ 50%
+"
+`;
+
+exports[`McpProgressIndicator > renders indeterminate progress with raw count 1`] = `
+"โโโโโโโโโโโโโโโโโโโโ 7
+"
+`;
+
+exports[`McpProgressIndicator > renders progress with a message 1`] = `
+"โโโโโโโโโโโโโโโโโโโโ 30%
+Downloading...
+"
+`;
diff --git a/packages/cli/src/ui/components/shared/SectionHeader.test.tsx b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
index 253e81f0f0..c7ff4e9c82 100644
--- a/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
+++ b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx
@@ -30,9 +30,15 @@ describe('', () => {
title: 'Narrow Container',
width: 25,
},
- ])('$description', async ({ title, width }) => {
+ {
+ description: 'renders correctly with a subtitle',
+ title: 'Shortcuts',
+ subtitle: ' See /help for more',
+ width: 40,
+ },
+ ])('$description', async ({ title, subtitle, width }) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
+ ,
{ width },
);
await waitUntilReady();
diff --git a/packages/cli/src/ui/components/shared/SectionHeader.tsx b/packages/cli/src/ui/components/shared/SectionHeader.tsx
index daa41379fb..3f0963ae50 100644
--- a/packages/cli/src/ui/components/shared/SectionHeader.tsx
+++ b/packages/cli/src/ui/components/shared/SectionHeader.tsx
@@ -8,16 +8,13 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
-export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
-
-
- {`โโ ${title}`}
-
+export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
+ title,
+ subtitle,
+}) => (
+
= ({ title }) => (
borderRight={false}
borderColor={theme.text.secondary}
/>
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
);
diff --git a/packages/cli/src/ui/components/shared/TabHeader.test.tsx b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
index 680ff51d28..c403e0d8ff 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.test.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.test.tsx
@@ -173,6 +173,28 @@ describe('TabHeader', () => {
unmount();
});
+ it('truncates long headers when not selected', async () => {
+ const longTabs: Tab[] = [
+ { key: '0', header: 'ThisIsAVeryLongHeaderThatShouldBeTruncated' },
+ { key: '1', header: 'AnotherVeryLongHeader' },
+ ];
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const frame = lastFrame();
+
+ // Current tab (index 0) should NOT be truncated
+ expect(frame).toContain('ThisIsAVeryLongHeaderThatShouldBeTruncated');
+
+ // Inactive tab (index 1) SHOULD be truncated to 16 chars (15 chars + โฆ)
+ const expectedTruncated = 'AnotherVeryLongโฆ';
+ expect(frame).toContain(expectedTruncated);
+ expect(frame).not.toContain('AnotherVeryLongHeader');
+
+ unmount();
+ });
+
it('falls back to default when renderStatusIcon returns undefined', async () => {
const renderStatusIcon = () => undefined;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
diff --git a/packages/cli/src/ui/components/shared/TabHeader.tsx b/packages/cli/src/ui/components/shared/TabHeader.tsx
index ad4e98cf3a..6ba93b37ff 100644
--- a/packages/cli/src/ui/components/shared/TabHeader.tsx
+++ b/packages/cli/src/ui/components/shared/TabHeader.tsx
@@ -94,16 +94,19 @@ export function TabHeader({
{showStatusIcons && (
{getStatusIcon(tab, i)}
)}
-
- {tab.header}
-
+
+
+ {tab.header}
+
+
))}
{showArrows && {' โ'}}
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
index 9968ec88d0..fb18e546a8 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap
@@ -1,16 +1,25 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > 'renders correctly in a narrow containโฆ' 1`] = `
-"โโ Narrow Container โโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโ
+Narrow Container
"
`;
exports[` > 'renders correctly when title is truncโฆ' 1`] = `
-"โโ Very Long Heaโฆ โโ
+"โโโโโโโโโโโโโโโโโโโโ
+Very Long Header Tiโฆ
"
`;
exports[` > 'renders correctly with a standard titโฆ' 1`] = `
-"โโ My Header โโโโโโโโโโโโโโโโโโโโโโโโโโโ
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+My Header
+"
+`;
+
+exports[` > 'renders correctly with a subtitle' 1`] = `
+"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+Shortcuts See /help for more
"
`;
diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts
index 93a3198ca8..795db1e3a0 100644
--- a/packages/cli/src/ui/constants.ts
+++ b/packages/cli/src/ui/constants.ts
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
-export const DEFAULT_BORDER_OPACITY = 0.2;
+export const DEFAULT_BORDER_OPACITY = 0.4;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 241b5d94f0..16365f4420 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -263,6 +263,41 @@ describe('toolMapping', () => {
expect(result.borderBottom).toBe(false);
});
+ it('maps raw progress and progressTotal from Executing calls', () => {
+ const toolCall: ExecutingToolCall = {
+ status: CoreToolCallStatus.Executing,
+ request: mockRequest,
+ tool: mockTool,
+ invocation: mockInvocation,
+ progressMessage: 'Downloading...',
+ progress: 5,
+ progressTotal: 10,
+ };
+
+ const result = mapToDisplay(toolCall);
+ const displayTool = result.tools[0];
+
+ expect(displayTool.progress).toBe(5);
+ expect(displayTool.progressTotal).toBe(10);
+ expect(displayTool.progressMessage).toBe('Downloading...');
+ });
+
+ it('leaves progress fields undefined for non-Executing calls', () => {
+ const toolCall: SuccessfulToolCall = {
+ status: CoreToolCallStatus.Success,
+ request: mockRequest,
+ tool: mockTool,
+ invocation: mockInvocation,
+ response: mockResponse,
+ };
+
+ const result = mapToDisplay(toolCall);
+ const displayTool = result.tools[0];
+
+ expect(displayTool.progress).toBeUndefined();
+ expect(displayTool.progressTotal).toBeUndefined();
+ });
+
it('sets resultDisplay to undefined for pre-execution statuses', () => {
const toolCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
@@ -275,5 +310,20 @@ describe('toolMapping', () => {
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
});
+
+ it('propagates originalRequestName correctly', () => {
+ const toolCall: ScheduledToolCall = {
+ status: CoreToolCallStatus.Scheduled,
+ request: {
+ ...mockRequest,
+ originalRequestName: 'original_tool',
+ },
+ tool: mockTool,
+ invocation: mockInvocation,
+ };
+
+ const result = mapToDisplay(toolCall);
+ expect(result.tools[0].originalRequestName).toBe('original_tool');
+ });
});
});
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index ded17f29a9..5a9db194ff 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -60,7 +60,8 @@ export function mapToDisplay(
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
let progressMessage: string | undefined = undefined;
- let progressPercent: number | undefined = undefined;
+ let progress: number | undefined = undefined;
+ let progressTotal: number | undefined = undefined;
switch (call.status) {
case CoreToolCallStatus.Success:
@@ -80,7 +81,8 @@ export function mapToDisplay(
resultDisplay = call.liveOutput;
ptyId = call.pid;
progressMessage = call.progressMessage;
- progressPercent = call.progressPercent;
+ progress = call.progress;
+ progressTotal = call.progressTotal;
break;
case CoreToolCallStatus.Scheduled:
case CoreToolCallStatus.Validating:
@@ -105,8 +107,10 @@ export function mapToDisplay(
ptyId,
correlationId,
progressMessage,
- progressPercent,
+ progress,
+ progressTotal,
approvalMode: call.approvalMode,
+ originalRequestName: call.request.originalRequestName,
};
});
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
index ddf43944f6..ca9df3d5d3 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
@@ -13,6 +13,7 @@ import {
Scheduler,
type Config,
type MessageBus,
+ type ExecutingToolCall,
type CompletedToolCall,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
@@ -110,7 +111,7 @@ describe('useToolScheduler', () => {
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
- };
+ } as ExecutingToolCall;
act(() => {
void mockMessageBus.publish({
@@ -405,4 +406,62 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
+
+ it('adapts success/error status to executing when a tail call is present', () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() =>
+ useToolScheduler(
+ vi.fn().mockResolvedValue(undefined),
+ mockConfig,
+ () => undefined,
+ ),
+ );
+
+ const startTime = Date.now();
+ vi.advanceTimersByTime(1000);
+
+ const mockToolCall = {
+ status: CoreToolCallStatus.Success as const,
+ request: {
+ callId: 'call-1',
+ name: 'test_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ },
+ tool: createMockTool(),
+ invocation: createMockInvocation(),
+ response: {
+ callId: 'call-1',
+ resultDisplay: 'OK',
+ responseParts: [],
+ error: undefined,
+ errorType: undefined,
+ },
+ tailToolCallRequest: {
+ name: 'tail_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: '123',
+ },
+ };
+
+ act(() => {
+ void mockMessageBus.publish({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [mockToolCall],
+ schedulerId: ROOT_SCHEDULER_ID,
+ } as ToolCallsUpdateMessage);
+ });
+
+ const [toolCalls, , , , , lastOutputTime] = result.current;
+
+ // Check if status has been adapted to 'executing'
+ expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
+
+ // Check if lastOutputTime was updated due to the transitional state
+ expect(lastOutputTime).toBeGreaterThan(startTime);
+
+ vi.useRealTimers();
+ });
});
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts
index 56b1622468..f09ed9b81f 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.ts
@@ -14,6 +14,7 @@ import {
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
+ CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -115,7 +116,16 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
- if (event.toolCalls.some((tc) => tc.status === 'executing')) {
+ const hasExecuting = event.toolCalls.some(
+ (tc) =>
+ tc.status === CoreToolCallStatus.Executing ||
+ ((tc.status === CoreToolCallStatus.Success ||
+ tc.status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in tc &&
+ tc.tailToolCallRequest != null),
+ );
+
+ if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
@@ -238,9 +248,23 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
+ let status = coreCall.status;
+ // If a tool call has completed but scheduled a tail call, it is in a transitional
+ // state. Force the UI to render it as "executing".
+ if (
+ (status === CoreToolCallStatus.Success ||
+ status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in coreCall &&
+ coreCall.tailToolCallRequest != null
+ ) {
+ status = CoreToolCallStatus.Executing;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...coreCall,
+ status,
responseSubmittedToGemini,
- };
+ } as TrackedToolCall;
});
}
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index b2de83cd8b..763754ec95 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
-
// Shell commands
{
command: Command.REVERSE_SEARCH,
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts
index 7c22e68b9a..30e34c2c12 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/no-color.ts
@@ -24,6 +24,8 @@ const noColorColorsTheme: ColorsTheme = {
Comment: '',
Gray: '',
DarkGray: '',
+ InputBackground: '',
+ MessageBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -36,6 +38,8 @@ const noColorSemanticColors: SemanticColors = {
},
background: {
primary: '',
+ message: '',
+ input: '',
diff: {
added: '',
removed: '',
diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts
index 3e95aee188..ca46fadb56 100644
--- a/packages/cli/src/ui/themes/semantic-tokens.ts
+++ b/packages/cli/src/ui/themes/semantic-tokens.ts
@@ -16,6 +16,8 @@ export interface SemanticColors {
};
background: {
primary: string;
+ message: string;
+ input: string;
diff: {
added: string;
removed: string;
@@ -48,13 +50,15 @@ export const lightSemanticColors: SemanticColors = {
},
background: {
primary: lightTheme.Background,
+ message: lightTheme.MessageBackground!,
+ input: lightTheme.InputBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
},
},
border: {
- default: lightTheme.Gray,
+ default: lightTheme.DarkGray,
focused: lightTheme.AccentBlue,
},
ui: {
@@ -80,13 +84,15 @@ export const darkSemanticColors: SemanticColors = {
},
background: {
primary: darkTheme.Background,
+ message: darkTheme.MessageBackground!,
+ input: darkTheme.InputBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
},
},
border: {
- default: darkTheme.Gray,
+ default: darkTheme.DarkGray,
focused: darkTheme.AccentBlue,
},
ui: {
diff --git a/packages/cli/src/ui/themes/solarized-dark.ts b/packages/cli/src/ui/themes/solarized-dark.ts
index d6b85ae90a..c2bf3db34d 100644
--- a/packages/cli/src/ui/themes/solarized-dark.ts
+++ b/packages/cli/src/ui/themes/solarized-dark.ts
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#002b36',
+ message: '#073642',
+ input: '#073642',
diff: {
added: '#00382f',
removed: '#3d0115',
diff --git a/packages/cli/src/ui/themes/solarized-light.ts b/packages/cli/src/ui/themes/solarized-light.ts
index 85d802f9dc..297238866d 100644
--- a/packages/cli/src/ui/themes/solarized-light.ts
+++ b/packages/cli/src/ui/themes/solarized-light.ts
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#fdf6e3',
+ message: '#eee8d5',
+ input: '#eee8d5',
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index b5875c8658..307666749b 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -29,7 +29,11 @@ import {
getThemeTypeFromBackgroundColor,
resolveColor,
} from './color-utils.js';
-import { DEFAULT_BORDER_OPACITY } from '../constants.js';
+import {
+ DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_BORDER_OPACITY,
+} from '../constants.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -310,7 +314,21 @@ class ThemeManager {
this.cachedColors = {
...colors,
Background: this.terminalBackground,
- DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
+ DarkGray: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_BORDER_OPACITY,
+ ),
+ InputBackground: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ this.terminalBackground,
+ colors.Gray,
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
};
} else {
this.cachedColors = colors;
@@ -336,27 +354,22 @@ class ThemeManager {
this.terminalBackground &&
this.isThemeCompatible(activeTheme, this.terminalBackground)
) {
+ const colors = this.getColors();
this.cachedSemanticColors = {
...semanticColors,
background: {
...semanticColors.background,
primary: this.terminalBackground,
+ message: colors.MessageBackground!,
+ input: colors.InputBackground!,
},
border: {
...semanticColors.border,
- default: interpolateColor(
- this.terminalBackground,
- activeTheme.colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: colors.DarkGray,
},
ui: {
...semanticColors.ui,
- dark: interpolateColor(
- activeTheme.colors.Gray,
- this.terminalBackground,
- 0.5,
- ),
+ dark: colors.DarkGray,
},
};
} else {
diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts
index 7240b04fa6..da6bd0cbc5 100644
--- a/packages/cli/src/ui/themes/theme.test.ts
+++ b/packages/cli/src/ui/themes/theme.test.ts
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
it('should interpolate DarkGray when not provided', () => {
const theme = createCustomTheme(baseTheme);
- // Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
+ // Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
// #cccccc is RGB(204, 204, 204)
// #000000 is RGB(0, 0, 0)
- // Midpoint is RGB(102, 102, 102) which is #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Result is RGB(82, 82, 82) which is #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
it('should use provided DarkGray', () => {
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
- // Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
it('should prefer text.secondary over Gray for interpolation', () => {
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
- // Interpolate between #cccccc and #000000 -> #666666
- expect(theme.colors.DarkGray).toBe('#666666');
+ // Interpolate between #000000 and #cccccc -> #525252
+ expect(theme.colors.DarkGray).toBe('#525252');
});
});
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index 2e39b1b6c7..c4277cd834 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -15,7 +15,11 @@ import {
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
-import { DEFAULT_BORDER_OPACITY } from '../constants.js';
+import {
+ DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_BORDER_OPACITY,
+} from '../constants.js';
export type { CustomTheme };
@@ -37,6 +41,8 @@ export interface ColorsTheme {
Comment: string;
Gray: string;
DarkGray: string;
+ InputBackground?: string;
+ MessageBackground?: string;
GradientColors?: string[];
}
@@ -55,7 +61,17 @@ export const lightTheme: ColorsTheme = {
DiffRemoved: '#FFCCCC',
Comment: '#008000',
Gray: '#97a0b0',
- DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
+ DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
+ InputBackground: interpolateColor(
+ '#FAFAFA',
+ '#97a0b0',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ '#FAFAFA',
+ '#97a0b0',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -74,7 +90,17 @@ export const darkTheme: ColorsTheme = {
DiffRemoved: '#430000',
Comment: '#6C7086',
Gray: '#6C7086',
- DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
+ DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
+ InputBackground: interpolateColor(
+ '#1E1E2E',
+ '#6C7086',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ '#1E1E2E',
+ '#6C7086',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -94,6 +120,8 @@ export const ansiTheme: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
+ InputBackground: 'black',
+ MessageBackground: 'black',
};
export class Theme {
@@ -131,17 +159,27 @@ export class Theme {
},
background: {
primary: this.colors.Background,
+ message:
+ this.colors.MessageBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.Gray,
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
+ input:
+ this.colors.InputBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.Gray,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
},
},
border: {
- default: interpolateColor(
- this.colors.Background,
- this.colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: this.colors.DarkGray,
focused: this.colors.AccentBlue,
},
ui: {
@@ -242,10 +280,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
DarkGray:
customTheme.DarkGray ??
interpolateColor(
- customTheme.text?.secondary ?? customTheme.Gray ?? '',
customTheme.background?.primary ?? customTheme.Background ?? '',
- 0.5,
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_BORDER_OPACITY,
),
+ InputBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
+ ),
+ MessageBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.text?.secondary ?? customTheme.Gray ?? '',
+ DEFAULT_BACKGROUND_OPACITY,
+ ),
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -400,19 +448,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
background: {
primary: customTheme.background?.primary ?? colors.Background,
+ message: colors.MessageBackground!,
+ input: colors.InputBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
},
},
border: {
- default:
- customTheme.border?.default ??
- interpolateColor(
- colors.Background,
- colors.Gray,
- DEFAULT_BORDER_OPACITY,
- ),
+ default: colors.DarkGray,
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 2d40f0a48c..ee958fcfb5 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -109,7 +109,9 @@ export interface IndividualToolCallDisplay {
correlationId?: string;
approvalMode?: ApprovalMode;
progressMessage?: string;
- progressPercent?: number;
+ originalRequestName?: string;
+ progress?: number;
+ progressTotal?: number;
}
export interface CompressionProps {
diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts
index 9f1d268a91..a6f903fe49 100644
--- a/packages/cli/src/utils/activityLogger.ts
+++ b/packages/cli/src/utils/activityLogger.ts
@@ -22,13 +22,82 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;
-/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
function isHeaderRecord(
h: http.OutgoingHttpHeaders | readonly string[],
): h is http.OutgoingHttpHeaders {
return !Array.isArray(h);
}
+function isRequestOptions(value: unknown): value is http.RequestOptions {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !(value instanceof URL) &&
+ !Array.isArray(value)
+ );
+}
+
+function isIncomingMessageCallback(
+ value: unknown,
+): value is (res: http.IncomingMessage) => void {
+ return typeof value === 'function';
+}
+
+type HttpRequestArgs =
+ | []
+ | [
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ];
+
+function callHttpRequest(
+ originalFn: typeof http.request,
+ args: HttpRequestArgs,
+): http.ClientRequest {
+ if (args.length === 0) {
+ return originalFn({});
+ }
+ if (args.length === 1) {
+ const first = args[0];
+ if (typeof first === 'string' || first instanceof URL) {
+ return originalFn(first);
+ }
+ if (isRequestOptions(first)) {
+ return originalFn(first);
+ }
+ return originalFn({});
+ }
+ if (args.length === 2) {
+ const first = args[0];
+ const second = args[1];
+ if (typeof first === 'string' || first instanceof URL) {
+ if (isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ if (isRequestOptions(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (args.length === 3) {
+ const first = args[0];
+ const second = args[1];
+ const third = args[2];
+ if (
+ (typeof first === 'string' || first instanceof URL) &&
+ isRequestOptions(second) &&
+ isIncomingMessageCallback(third)
+ ) {
+ return originalFn(first, second, third);
+ }
+ }
+ return originalFn({});
+}
+
export interface NetworkLog {
id: string;
timestamp: number;
@@ -364,7 +433,7 @@ export class ActivityLogger extends EventEmitter {
const wrapRequest = (
originalFn: typeof http.request,
- args: unknown[],
+ args: HttpRequestArgs,
protocol: string,
) => {
const firstArg = args[0];
@@ -373,8 +442,10 @@ export class ActivityLogger extends EventEmitter {
options = firstArg;
} else if (firstArg instanceof URL) {
options = firstArg;
+ } else if (firstArg && typeof firstArg === 'object') {
+ options = isRequestOptions(firstArg) ? firstArg : {};
} else {
- options = (firstArg ?? {}) as http.RequestOptions;
+ options = {};
}
let url = '';
@@ -393,9 +464,9 @@ export class ActivityLogger extends EventEmitter {
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
}
- if (url.includes('127.0.0.1') || url.includes('localhost'))
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ if (url.includes('127.0.0.1') || url.includes('localhost')) {
+ return callHttpRequest(originalFn, args);
+ }
const rawHeaders =
typeof options === 'object' &&
@@ -410,24 +481,23 @@ export class ActivityLogger extends EventEmitter {
if (headers[ACTIVITY_ID_HEADER]) {
delete headers[ACTIVITY_ID_HEADER];
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ return callHttpRequest(originalFn, args);
}
const id = Math.random().toString(36).substring(7);
this.requestStartTimes.set(id, Date.now());
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- const req = originalFn.apply(http, args as any);
+ const req = callHttpRequest(originalFn, args);
const requestChunks: Buffer[] = [];
const oldWrite = req.write;
const oldEnd = req.end;
- req.write = function (chunk: unknown, ...etc: unknown[]) {
+ req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -438,19 +508,21 @@ export class ActivityLogger extends EventEmitter {
),
);
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return oldWrite.apply(this, [chunk, ...etc] as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
+ return (oldWrite as any).apply(this, [chunk, ...etc]);
};
req.end = function (
this: http.ClientRequest,
- chunk: unknown,
+ chunkOrCb?: string | Uint8Array | (() => void),
...etc: unknown[]
) {
- if (chunk && typeof chunk !== 'function') {
+ const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
+ if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -473,7 +545,7 @@ export class ActivityLogger extends EventEmitter {
pending: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
- return (oldEnd as any).apply(this, [chunk, ...etc]);
+ return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
};
req.on('response', (res: http.IncomingMessage) => {
@@ -545,12 +617,44 @@ export class ActivityLogger extends EventEmitter {
return req;
};
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (http as any).request = (...args: unknown[]) =>
- wrapRequest(originalRequest, args, 'http:');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (https as any).request = (...args: unknown[]) =>
- wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
+ Object.defineProperty(http, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(originalRequest, args, 'http:');
+ },
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(https, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(
+ originalHttpsRequest as typeof http.request,
+ args,
+ 'https:',
+ );
+ },
+ writable: true,
+ configurable: true,
+ });
}
logConsole(payload: ConsoleLogPayload) {
diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/zed-integration/acpResume.test.ts
index f814a9e586..54c04a0ff3 100644
--- a/packages/cli/src/zed-integration/acpResume.test.ts
+++ b/packages/cli/src/zed-integration/acpResume.test.ts
@@ -48,6 +48,24 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal();
return {
...actual,
+ CoreToolCallStatus: {
+ Validating: 'validating',
+ Scheduled: 'scheduled',
+ Error: 'error',
+ Success: 'success',
+ Executing: 'executing',
+ Cancelled: 'cancelled',
+ AwaitingApproval: 'awaiting_approval',
+ },
+ LlmRole: {
+ MAIN: 'main',
+ SUBAGENT: 'subagent',
+ UTILITY_TOOL: 'utility_tool',
+ USER: 'user',
+ MODEL: 'model',
+ SYSTEM: 'system',
+ TOOL: 'tool',
+ },
convertSessionToClientHistory: vi.fn(),
};
});
@@ -256,6 +274,7 @@ describe('GeminiAgent Session Resume', () => {
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
+ kind: 'read',
}),
}),
);
diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts
index cc71dd9309..f1cb22bfda 100644
--- a/packages/cli/src/zed-integration/zedIntegration.test.ts
+++ b/packages/cli/src/zed-integration/zedIntegration.test.ts
@@ -73,7 +73,7 @@ vi.mock(
...actual,
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
- kind: 'native',
+ kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
@@ -84,6 +84,28 @@ vi.mock(
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
+ LlmRole: {
+ MAIN: 'main',
+ SUBAGENT: 'subagent',
+ UTILITY_TOOL: 'utility_tool',
+ UTILITY_COMPRESSOR: 'utility_compressor',
+ UTILITY_SUMMARIZER: 'utility_summarizer',
+ UTILITY_ROUTER: 'utility_router',
+ UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
+ UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
+ UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
+ UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
+ UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
+ },
+ CoreToolCallStatus: {
+ Validating: 'validating',
+ Scheduled: 'scheduled',
+ Error: 'error',
+ Success: 'success',
+ Executing: 'executing',
+ Cancelled: 'cancelled',
+ AwaitingApproval: 'awaiting_approval',
+ },
};
},
);
@@ -406,7 +428,7 @@ describe('Session', () => {
recordCompletedToolCalls: vi.fn(),
} as unknown as Mocked;
mockTool = {
- kind: 'native',
+ kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
@@ -511,6 +533,7 @@ describe('Session', () => {
update: expect.objectContaining({
sessionUpdate: 'tool_call',
status: 'in_progress',
+ kind: 'read',
}),
}),
);
@@ -632,6 +655,92 @@ describe('Session', () => {
);
});
+ it('should include _meta.kind in diff tool calls', async () => {
+ // Test 'add' (no original content)
+ const addConfirmation = {
+ type: 'edit',
+ fileName: 'new.txt',
+ originalContent: null,
+ newContent: 'New content',
+ onConfirm: vi.fn(),
+ };
+
+ // Test 'modify' (original and new content)
+ const modifyConfirmation = {
+ type: 'edit',
+ fileName: 'existing.txt',
+ originalContent: 'Old content',
+ newContent: 'New content',
+ onConfirm: vi.fn(),
+ };
+
+ // Test 'delete' (original content, no new content)
+ const deleteConfirmation = {
+ type: 'edit',
+ fileName: 'deleted.txt',
+ originalContent: 'Old content',
+ newContent: '',
+ onConfirm: vi.fn(),
+ };
+
+ const mockBuild = vi.fn();
+ mockTool.build = mockBuild;
+
+ // Helper to simulate tool call and check permission request
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const checkDiffKind = async (confirmation: any, expectedKind: string) => {
+ mockBuild.mockReturnValueOnce({
+ getDescription: () => 'Test Tool',
+ toolLocations: () => [],
+ shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation),
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }),
+ });
+
+ mockConnection.requestPermission.mockResolvedValueOnce({
+ outcome: {
+ outcome: 'selected',
+ optionId: ToolConfirmationOutcome.ProceedOnce,
+ },
+ });
+
+ const stream = createMockStream([
+ {
+ type: StreamEventType.CHUNK,
+ value: {
+ functionCalls: [{ name: 'test_tool', args: {} }],
+ },
+ },
+ ]);
+ const emptyStream = createMockStream([]);
+
+ mockChat.sendMessageStream
+ .mockResolvedValueOnce(stream)
+ .mockResolvedValueOnce(emptyStream);
+
+ await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: 'Call tool' }],
+ });
+
+ expect(mockConnection.requestPermission).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toolCall: expect.objectContaining({
+ content: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'diff',
+ _meta: { kind: expectedKind },
+ }),
+ ]),
+ }),
+ }),
+ );
+ };
+
+ await checkDiffKind(addConfirmation, 'add');
+ await checkDiffKind(modifyConfirmation, 'modify');
+ await checkDiffKind(deleteConfirmation, 'delete');
+ });
+
it('should handle @path resolution', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
(fs.stat as unknown as Mock).mockResolvedValue({
diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts
index 1dce8d5e6d..f6c0a63349 100644
--- a/packages/cli/src/zed-integration/zedIntegration.ts
+++ b/packages/cli/src/zed-integration/zedIntegration.ts
@@ -682,6 +682,13 @@ export class Session {
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
+ _meta: {
+ kind: !confirmationDetails.originalContent
+ ? 'add'
+ : confirmationDetails.newContent === ''
+ ? 'delete'
+ : 'modify',
+ },
});
}
@@ -1203,6 +1210,13 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
path: toolResult.returnDisplay.fileName,
oldText: toolResult.returnDisplay.originalContent,
newText: toolResult.returnDisplay.newContent,
+ _meta: {
+ kind: !toolResult.returnDisplay.originalContent
+ ? 'add'
+ : toolResult.returnDisplay.newContent === ''
+ ? 'delete'
+ : 'modify',
+ },
};
}
return null;
@@ -1291,14 +1305,16 @@ function toAcpToolKind(kind: Kind): acp.ToolKind {
switch (kind) {
case Kind.Read:
case Kind.Edit:
+ case Kind.Execute:
+ case Kind.Search:
case Kind.Delete:
case Kind.Move:
- case Kind.Search:
- case Kind.Execute:
case Kind.Think:
case Kind.Fetch:
+ case Kind.SwitchMode:
case Kind.Other:
return kind as acp.ToolKind;
+ case Kind.Plan:
case Kind.Communicate:
default:
return 'other';
diff --git a/packages/core/package.json b/packages/core/package.json
index e01efe9b3f..9995dabe18 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,6 +53,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
diff --git a/packages/core/src/agents/browser/analyzeScreenshot.test.ts b/packages/core/src/agents/browser/analyzeScreenshot.test.ts
new file mode 100644
index 0000000000..71e082b75d
--- /dev/null
+++ b/packages/core/src/agents/browser/analyzeScreenshot.test.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+
+const mockMessageBus = {
+ waitForConfirmation: vi.fn().mockResolvedValue({ approved: true }),
+} as unknown as MessageBus;
+
+function createMockBrowserManager(
+ callToolResult?: McpToolCallResult,
+): BrowserManager {
+ return {
+ callTool: vi.fn().mockResolvedValue(
+ callToolResult ?? {
+ content: [
+ { type: 'text', text: 'Screenshot captured' },
+ {
+ type: 'image',
+ data: 'base64encodeddata',
+ mimeType: 'image/png',
+ },
+ ],
+ },
+ ),
+ } as unknown as BrowserManager;
+}
+
+function createMockConfig(
+ generateContentResult?: unknown,
+ generateContentError?: Error,
+): Config {
+ const generateContent = generateContentError
+ ? vi.fn().mockRejectedValue(generateContentError)
+ : vi.fn().mockResolvedValue(
+ generateContentResult ?? {
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ text: 'The blue submit button is at coordinates (250, 400).',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ );
+
+ return {
+ getBrowserAgentConfig: vi.fn().mockReturnValue({
+ customConfig: { visualModel: 'test-visual-model' },
+ }),
+ getContentGenerator: vi.fn().mockReturnValue({
+ generateContent,
+ }),
+ } as unknown as Config;
+}
+
+describe('analyzeScreenshot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('createAnalyzeScreenshotTool', () => {
+ it('creates a tool with the correct name and schema', () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ expect(tool.name).toBe('analyze_screenshot');
+ });
+ });
+
+ describe('AnalyzeScreenshotInvocation', () => {
+ it('captures a screenshot and returns visual analysis', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the blue submit button',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ // Verify screenshot was captured
+ expect(browserManager.callTool).toHaveBeenCalledWith(
+ 'take_screenshot',
+ {},
+ );
+
+ // Verify the visual model was called
+ const contentGenerator = config.getContentGenerator();
+ expect(contentGenerator.generateContent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: 'test-visual-model',
+ contents: expect.arrayContaining([
+ expect.objectContaining({
+ role: 'user',
+ parts: expect.arrayContaining([
+ expect.objectContaining({
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'base64encodeddata',
+ },
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ 'visual-analysis',
+ 'utility_tool',
+ );
+
+ // Verify result
+ expect(result.llmContent).toContain('Visual Analysis Result');
+ expect(result.llmContent).toContain(
+ 'The blue submit button is at coordinates (250, 400).',
+ );
+ expect(result.error).toBeUndefined();
+ });
+
+ it('returns an error when screenshot capture fails (no image)', async () => {
+ const browserManager = createMockBrowserManager({
+ content: [{ type: 'text', text: 'No screenshot available' }],
+ });
+ const config = createMockConfig();
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the button',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Failed to capture screenshot');
+ // Should NOT call the visual model
+ const contentGenerator = config.getContentGenerator();
+ expect(contentGenerator.generateContent).not.toHaveBeenCalled();
+ });
+
+ it('returns an error when visual model returns empty response', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig({
+ candidates: [{ content: { parts: [] } }],
+ });
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Check the layout',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Visual model returned no analysis');
+ });
+
+ it('returns a model-unavailability fallback for 404 errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(
+ undefined,
+ new Error('Model not found: 404'),
+ );
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find the red error',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain(
+ 'Visual analysis model is not available',
+ );
+ });
+
+ it('returns a model-unavailability fallback for 403 errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(
+ undefined,
+ new Error('permission denied: 403'),
+ );
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Identify the element',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain(
+ 'Visual analysis model is not available',
+ );
+ });
+
+ it('returns a generic error for non-model errors', async () => {
+ const browserManager = createMockBrowserManager();
+ const config = createMockConfig(undefined, new Error('Network timeout'));
+ const tool = createAnalyzeScreenshotTool(
+ browserManager,
+ config,
+ mockMessageBus,
+ );
+
+ const invocation = tool.build({
+ instruction: 'Find something',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.llmContent).toContain('Visual analysis failed');
+ expect(result.llmContent).toContain('Network timeout');
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/analyzeScreenshot.ts b/packages/core/src/agents/browser/analyzeScreenshot.ts
new file mode 100644
index 0000000000..c269b71bfb
--- /dev/null
+++ b/packages/core/src/agents/browser/analyzeScreenshot.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Tool for visual identification via a single model call.
+ *
+ * The semantic browser agent uses this tool when it needs to identify
+ * elements by visual attributes not present in the accessibility tree
+ * (e.g., color, layout, precise coordinates).
+ *
+ * Unlike the semantic agent which works with the accessibility tree,
+ * this tool sends a screenshot to a computer-use model for visual analysis.
+ * It returns the model's analysis (coordinates, element descriptions) back
+ * to the browser agent, which retains full control of subsequent actions.
+ */
+
+import {
+ DeclarativeTool,
+ BaseToolInvocation,
+ Kind,
+ type ToolResult,
+ type ToolInvocation,
+} from '../../tools/tools.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager } from './browserManager.js';
+import type { Config } from '../../config/config.js';
+import { getVisualAgentModel } from './modelAvailability.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import { LlmRole } from '../../telemetry/llmRole.js';
+
+/**
+ * System prompt for the visual analysis model call.
+ */
+const VISUAL_SYSTEM_PROMPT = `You are a Visual Analysis Agent. You receive a screenshot of a browser page and an instruction.
+
+Your job is to ANALYZE the screenshot and provide precise information that a browser automation agent can act on.
+
+COORDINATE SYSTEM:
+- Coordinates are pixel-based relative to the viewport
+- (0,0) is top-left of the visible area
+- Estimate element positions from the screenshot
+
+RESPONSE FORMAT:
+- For coordinate identification: provide exact (x, y) pixel coordinates
+- For element identification: describe the element's visual location and appearance
+- For layout analysis: describe the spatial relationships between elements
+- Be concise and actionable โ the browser agent will use your response to decide what action to take
+
+IMPORTANT:
+- You are NOT performing actions โ you are only providing visual analysis
+- Include coordinates when possible so the caller can use click_at(x, y)
+- If the element is not visible in the screenshot, say so explicitly`;
+
+/**
+ * Invocation for the analyze_screenshot tool.
+ * Makes a single generateContent call with a screenshot.
+ */
+class AnalyzeScreenshotInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly config: Config,
+ params: Record,
+ messageBus: MessageBus,
+ ) {
+ super(params, messageBus, 'analyze_screenshot', 'Analyze Screenshot');
+ }
+
+ getDescription(): string {
+ const instruction = String(this.params['instruction'] ?? '');
+ return `Visual analysis: "${instruction}"`;
+ }
+
+ async execute(signal: AbortSignal): Promise {
+ try {
+ const instruction = String(this.params['instruction'] ?? '');
+
+ debugLogger.log(`Visual analysis requested: ${instruction}`);
+
+ // Capture screenshot via MCP tool
+ const screenshotResult = await this.browserManager.callTool(
+ 'take_screenshot',
+ {},
+ );
+
+ // Extract base64 image data from MCP response.
+ // Search ALL content items for image type โ MCP returns [text, image]
+ // where content[0] is a text description and content[1] is the actual PNG.
+ let screenshotBase64 = '';
+ let mimeType = 'image/png';
+ if (screenshotResult.content && Array.isArray(screenshotResult.content)) {
+ for (const item of screenshotResult.content) {
+ if (item.type === 'image' && item.data) {
+ screenshotBase64 = item.data;
+ mimeType = item.mimeType ?? 'image/png';
+ break;
+ }
+ }
+ }
+
+ if (!screenshotBase64) {
+ return {
+ llmContent:
+ 'Failed to capture screenshot for visual analysis. Use accessibility tree elements instead.',
+ returnDisplay: 'Screenshot capture failed',
+ error: { message: 'Screenshot capture failed' },
+ };
+ }
+
+ // Make a single generateContent call with the visual model
+ const visualModel = getVisualAgentModel(this.config);
+ const contentGenerator = this.config.getContentGenerator();
+
+ const response = await contentGenerator.generateContent(
+ {
+ model: visualModel,
+ config: {
+ temperature: 0,
+ topP: 0.95,
+ systemInstruction: VISUAL_SYSTEM_PROMPT,
+ abortSignal: signal,
+ },
+ contents: [
+ {
+ role: 'user',
+ parts: [
+ {
+ text: `Analyze this screenshot and respond to the following instruction:\n\n${instruction}`,
+ },
+ {
+ inlineData: {
+ mimeType,
+ data: screenshotBase64,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ 'visual-analysis',
+ LlmRole.UTILITY_TOOL,
+ );
+
+ // Extract text from response
+ const responseText =
+ response.candidates?.[0]?.content?.parts
+ ?.filter((p) => p.text)
+ .map((p) => p.text)
+ .join('\n') ?? '';
+
+ if (!responseText) {
+ return {
+ llmContent:
+ 'Visual model returned no analysis. Use accessibility tree elements instead.',
+ returnDisplay: 'Visual analysis returned empty response',
+ error: { message: 'Empty visual analysis response' },
+ };
+ }
+
+ debugLogger.log(`Visual analysis complete: ${responseText}`);
+
+ return {
+ llmContent: `Visual Analysis Result:\n${responseText}`,
+ returnDisplay: `Visual Analysis Result:\n${responseText}`,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ debugLogger.error(`Visual analysis failed: ${errorMsg}`);
+
+ // Provide a graceful fallback message for model unavailability
+ const isModelError =
+ errorMsg.includes('404') ||
+ errorMsg.includes('403') ||
+ errorMsg.includes('not found') ||
+ errorMsg.includes('permission');
+
+ const fallbackMsg = isModelError
+ ? 'Visual analysis model is not available. Use accessibility tree elements (uids from take_snapshot) for all interactions instead.'
+ : `Visual analysis failed: ${errorMsg}. Use accessibility tree elements instead.`;
+
+ return {
+ llmContent: fallbackMsg,
+ returnDisplay: fallbackMsg,
+ error: { message: errorMsg },
+ };
+ }
+ }
+}
+
+/**
+ * DeclarativeTool for screenshot-based visual analysis.
+ */
+class AnalyzeScreenshotTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly config: Config,
+ messageBus: MessageBus,
+ ) {
+ super(
+ 'analyze_screenshot',
+ 'analyze_screenshot',
+ 'Analyze the current page visually using a screenshot. Use when you need to identify elements by visual attributes (color, layout, position) not available in the accessibility tree, or when you need precise pixel coordinates for click_at. Returns visual analysis โ you perform the actions yourself.',
+ Kind.Other,
+ {
+ type: 'object',
+ properties: {
+ instruction: {
+ type: 'string',
+ description:
+ 'What to identify or analyze visually (e.g., "Find the coordinates of the blue submit button", "What is the layout of the navigation menu?").',
+ },
+ },
+ required: ['instruction'],
+ },
+ messageBus,
+ true, // isOutputMarkdown
+ false, // canUpdateOutput
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ return new AnalyzeScreenshotInvocation(
+ this.browserManager,
+ this.config,
+ params,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * Creates the analyze_screenshot tool for the browser agent.
+ */
+export function createAnalyzeScreenshotTool(
+ browserManager: BrowserManager,
+ config: Config,
+ messageBus: MessageBus,
+): AnalyzeScreenshotTool {
+ return new AnalyzeScreenshotTool(browserManager, config, messageBus);
+}
diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts
new file mode 100644
index 0000000000..2703f53930
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentDefinition.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Browser Agent definition following the LocalAgentDefinition pattern.
+ *
+ * This agent uses LocalAgentExecutor for its reAct loop, like CodebaseInvestigatorAgent.
+ * It is available ONLY via delegate_to_agent, NOT as a direct tool.
+ *
+ * Tools are configured dynamically at invocation time via browserAgentFactory.
+ */
+
+import type { LocalAgentDefinition } from '../types.js';
+import type { Config } from '../../config/config.js';
+import { z } from 'zod';
+import {
+ isPreviewModel,
+ PREVIEW_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_FLASH_MODEL,
+} from '../../config/models.js';
+
+/** Canonical agent name โ used for routing and configuration lookup. */
+export const BROWSER_AGENT_NAME = 'browser_agent';
+
+/**
+ * Output schema for browser agent results.
+ */
+export const BrowserTaskResultSchema = z.object({
+ success: z.boolean().describe('Whether the task was completed successfully'),
+ summary: z
+ .string()
+ .describe('A summary of what was accomplished or what went wrong'),
+ data: z
+ .unknown()
+ .optional()
+ .describe('Optional extracted data from the task'),
+});
+
+const VISUAL_SECTION = `
+VISUAL IDENTIFICATION (analyze_screenshot):
+When you need to identify elements by visual attributes not in the AX tree (e.g., "click the yellow button", "find the red error message"), or need precise pixel coordinates:
+1. Call analyze_screenshot with a clear instruction describing what to find
+2. It returns visual analysis with coordinates/descriptions โ it does NOT perform actions
+3. Use the returned coordinates with click_at(x, y) or other tools yourself
+4. If the analysis is insufficient, call it again with a more specific instruction
+`;
+
+/**
+ * System prompt for the semantic browser agent.
+ * Extracted from prototype (computer_use_subagent_cdt branch).
+ *
+ * @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
+ */
+export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
+ return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
+
+IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
+Use these uid values directly with your tools:
+- click(uid="87_4") to click the Login button
+- fill(uid="87_2", value="john") to fill a text field
+- fill_form(elements=[{uid: "87_2", value: "john"}, {uid: "87_3", value: "pass"}]) to fill multiple fields at once
+
+PARALLEL TOOL CALLS - CRITICAL:
+- Do NOT make parallel calls for actions that change page state (click, fill, press_key, etc.)
+- Each action changes the DOM and invalidates UIDs from the current snapshot
+- Make state-changing actions ONE AT A TIME, then observe the results
+
+OVERLAY/POPUP HANDLING:
+Before interacting with page content, scan the accessibility tree for blocking overlays:
+- Tooltips, popups, modals, cookie banners, newsletter prompts, promo dialogs
+- These often have: close buttons (ร, X, Close, Dismiss), "Got it", "Accept", "No thanks" buttons
+- Common patterns: elements with role="dialog", role="tooltip", role="alertdialog", or aria-modal="true"
+- If you see such elements, DISMISS THEM FIRST by clicking close/dismiss buttons before proceeding
+- If a click seems to have no effect, check if an overlay appeared or is blocking the target
+${visionEnabled ? VISUAL_SECTION : ''}
+
+COMPLEX WEB APPS (spreadsheets, rich editors, canvas apps):
+Many web apps (Google Sheets/Docs, Notion, Figma, etc.) use custom rendering rather than standard HTML inputs.
+- fill does NOT work on these apps. Instead, click the target element, then use type_text to enter the value.
+- type_text supports a submitKey parameter to press a key after typing (e.g., submitKey="Enter" to submit, submitKey="Tab" to move to the next field). This is much faster than separate press_key calls.
+- Navigate cells/fields using keyboard shortcuts (Tab, Enter, ArrowDown) โ more reliable than clicking UIDs.
+- Use the Name Box (cell reference input, usually showing "A1") to jump to specific cells.
+
+TERMINAL FAILURES โ STOP IMMEDIATELY:
+Some errors are unrecoverable and retrying will never help. When you see ANY of these, call complete_task immediately with success=false and include the EXACT error message (including any remediation steps it contains) in your summary:
+- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" โ Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
+- "Browser closed" or "Target closed" or "Session closed" โ The browser process has terminated. Include the error and tell the user to try again.
+- "net::ERR_" network errors on the SAME URL after 2 retries โ the site is unreachable. Report the URL and error.
+- Any error that appears IDENTICALLY 3+ times in a row โ it will not resolve by retrying.
+Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
+
+CRITICAL: When you have fully completed the user's task, you MUST call the complete_task tool with a summary of what you accomplished. Do NOT just return text - you must explicitly call complete_task to exit the loop.`;
+}
+
+/**
+ * Browser Agent Definition Factory.
+ *
+ * Following the CodebaseInvestigatorAgent pattern:
+ * - Returns a factory function that takes Config for dynamic model selection
+ * - kind: 'local' for LocalAgentExecutor
+ * - toolConfig is set dynamically by browserAgentFactory
+ */
+export const BrowserAgentDefinition = (
+ config: Config,
+ visionEnabled = false,
+): LocalAgentDefinition => {
+ // Use Preview Flash model if the main model is any of the preview models.
+ // If the main model is not a preview model, use the default flash model.
+ const model = isPreviewModel(config.getModel())
+ ? PREVIEW_GEMINI_FLASH_MODEL
+ : DEFAULT_GEMINI_FLASH_MODEL;
+
+ return {
+ name: BROWSER_AGENT_NAME,
+ kind: 'local',
+ experimental: true,
+ displayName: 'Browser Agent',
+ description: `Specialized autonomous agent for end-to-end web browser automation and objective-driven problem solving. Delegate complete, high-level tasks to this agent โ it independently plans, executes multi-step interactions, interprets dynamic page feedback (e.g., game states, form validation errors, search results), and iterates until the goal is achieved. It perceives page structure through the Accessibility Tree, handles overlays and popups, and supports complex web apps.`,
+
+ inputConfig: {
+ inputSchema: {
+ type: 'object',
+ properties: {
+ task: {
+ type: 'string',
+ description: 'The task to perform in the browser.',
+ },
+ },
+ required: ['task'],
+ },
+ },
+
+ outputConfig: {
+ outputName: 'result',
+ description: 'The result of the browser task.',
+ schema: BrowserTaskResultSchema,
+ },
+
+ processOutput: (output) => JSON.stringify(output, null, 2),
+
+ modelConfig: {
+ // Dynamic model based on whether user is using preview models
+ model,
+ generateContentConfig: {
+ temperature: 0.1,
+ topP: 0.95,
+ },
+ },
+
+ runConfig: {
+ maxTimeMinutes: 10,
+ maxTurns: 50,
+ },
+
+ // Tools are set dynamically by browserAgentFactory after MCP connection
+ // This is undefined here and will be set at invocation time
+ toolConfig: undefined,
+
+ promptConfig: {
+ query: `Your task is:
+
+\${task}
+
+
+First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
+ systemPrompt: buildBrowserSystemPrompt(visionEnabled),
+ },
+ };
+};
diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts
new file mode 100644
index 0000000000..a317f3a9ed
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts
@@ -0,0 +1,258 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ createBrowserAgentDefinition,
+ cleanupBrowserAgent,
+} from './browserAgentFactory.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager } from './browserManager.js';
+
+// Create mock browser manager
+const mockBrowserManager = {
+ ensureConnection: vi.fn().mockResolvedValue(undefined),
+ getDiscoveredTools: vi.fn().mockResolvedValue([
+ // Semantic tools
+ { name: 'take_snapshot', description: 'Take snapshot' },
+ { name: 'click', description: 'Click element' },
+ { name: 'fill', description: 'Fill form field' },
+ { name: 'navigate_page', description: 'Navigate to URL' },
+ // Visual tools (from --experimental-vision)
+ { name: 'click_at', description: 'Click at coordinates' },
+ ]),
+ callTool: vi.fn().mockResolvedValue({ content: [] }),
+ close: vi.fn().mockResolvedValue(undefined),
+};
+
+// Mock dependencies
+vi.mock('./browserManager.js', () => ({
+ BrowserManager: vi.fn(() => mockBrowserManager),
+}));
+
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import {
+ buildBrowserSystemPrompt,
+ BROWSER_AGENT_NAME,
+} from './browserAgentDefinition.js';
+
+describe('browserAgentFactory', () => {
+ let mockConfig: Config;
+ let mockMessageBus: MessageBus;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Reset mock implementations
+ mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
+ mockBrowserManager.getDiscoveredTools.mockResolvedValue([
+ // Semantic tools
+ { name: 'take_snapshot', description: 'Take snapshot' },
+ { name: 'click', description: 'Click element' },
+ { name: 'fill', description: 'Fill form field' },
+ { name: 'navigate_page', description: 'Navigate to URL' },
+ // Visual tools (from --experimental-vision)
+ { name: 'click_at', description: 'Click at coordinates' },
+ ]);
+ mockBrowserManager.close.mockResolvedValue(undefined);
+
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ },
+ },
+ });
+
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('createBrowserAgentDefinition', () => {
+ it('should ensure browser connection', async () => {
+ await createBrowserAgentDefinition(mockConfig, mockMessageBus);
+
+ expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
+ });
+
+ it('should return agent definition with discovered tools', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(definition.name).toBe(BROWSER_AGENT_NAME);
+ // 5 MCP tools + 1 type_text composite tool (no analyze_screenshot without visualModel)
+ expect(definition.toolConfig?.tools).toHaveLength(6);
+ });
+
+ it('should return browser manager for cleanup', async () => {
+ const { browserManager } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(browserManager).toBeDefined();
+ });
+
+ it('should call printOutput when provided', async () => {
+ const printOutput = vi.fn();
+
+ await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ printOutput,
+ );
+
+ expect(printOutput).toHaveBeenCalled();
+ });
+
+ it('should create definition with correct structure', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ expect(definition.kind).toBe('local');
+ expect(definition.inputConfig).toBeDefined();
+ expect(definition.outputConfig).toBeDefined();
+ expect(definition.promptConfig).toBeDefined();
+ });
+
+ it('should exclude visual prompt section when visualModel is not configured', async () => {
+ const { definition } = await createBrowserAgentDefinition(
+ mockConfig,
+ mockMessageBus,
+ );
+
+ const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
+ expect(systemPrompt).not.toContain('analyze_screenshot');
+ expect(systemPrompt).not.toContain('VISUAL IDENTIFICATION');
+ });
+
+ it('should include visual prompt section when visualModel is configured', async () => {
+ const configWithVision = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ visualModel: 'gemini-2.5-flash-preview',
+ },
+ },
+ });
+
+ const { definition } = await createBrowserAgentDefinition(
+ configWithVision,
+ mockMessageBus,
+ );
+
+ const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
+ expect(systemPrompt).toContain('analyze_screenshot');
+ expect(systemPrompt).toContain('VISUAL IDENTIFICATION');
+ });
+
+ it('should include analyze_screenshot tool when visualModel is configured', async () => {
+ const configWithVision = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ visualModel: 'gemini-2.5-flash-preview',
+ },
+ },
+ });
+
+ const { definition } = await createBrowserAgentDefinition(
+ configWithVision,
+ mockMessageBus,
+ );
+
+ // 5 MCP tools + 1 type_text + 1 analyze_screenshot
+ expect(definition.toolConfig?.tools).toHaveLength(7);
+ const toolNames =
+ definition.toolConfig?.tools
+ ?.filter(
+ (t): t is { name: string } => typeof t === 'object' && 'name' in t,
+ )
+ .map((t) => t.name) ?? [];
+ expect(toolNames).toContain('analyze_screenshot');
+ });
+ });
+
+ describe('cleanupBrowserAgent', () => {
+ it('should call close on browser manager', async () => {
+ await cleanupBrowserAgent(
+ mockBrowserManager as unknown as BrowserManager,
+ );
+
+ expect(mockBrowserManager.close).toHaveBeenCalled();
+ });
+
+ it('should handle errors during cleanup gracefully', async () => {
+ const errorManager = {
+ close: vi.fn().mockRejectedValue(new Error('Close failed')),
+ } as unknown as BrowserManager;
+
+ // Should not throw
+ await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined();
+ });
+ });
+});
+
+describe('buildBrowserSystemPrompt', () => {
+ it('should include visual section when vision is enabled', () => {
+ const prompt = buildBrowserSystemPrompt(true);
+ expect(prompt).toContain('VISUAL IDENTIFICATION');
+ expect(prompt).toContain('analyze_screenshot');
+ expect(prompt).toContain('click_at');
+ });
+
+ it('should exclude visual section when vision is disabled', () => {
+ const prompt = buildBrowserSystemPrompt(false);
+ expect(prompt).not.toContain('VISUAL IDENTIFICATION');
+ expect(prompt).not.toContain('analyze_screenshot');
+ });
+
+ it('should always include core sections regardless of vision', () => {
+ for (const visionEnabled of [true, false]) {
+ const prompt = buildBrowserSystemPrompt(visionEnabled);
+ expect(prompt).toContain('PARALLEL TOOL CALLS');
+ expect(prompt).toContain('OVERLAY/POPUP HANDLING');
+ expect(prompt).toContain('COMPLEX WEB APPS');
+ expect(prompt).toContain('TERMINAL FAILURES');
+ expect(prompt).toContain('complete_task');
+ }
+ });
+});
diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts
new file mode 100644
index 0000000000..a8a3b0f338
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentFactory.ts
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Factory for creating browser agent definitions with configured tools.
+ *
+ * This factory is called when the browser agent is invoked via delegate_to_agent.
+ * It creates a BrowserManager, connects the isolated MCP client, wraps tools,
+ * and returns a fully configured LocalAgentDefinition.
+ *
+ * IMPORTANT: The MCP tools are ONLY available to the browser agent's isolated
+ * registry. They are NOT registered in the main agent's ToolRegistry.
+ */
+
+import type { Config } from '../../config/config.js';
+import { AuthType } from '../../core/contentGenerator.js';
+import type { LocalAgentDefinition } from '../types.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { AnyDeclarativeTool } from '../../tools/tools.js';
+import { BrowserManager } from './browserManager.js';
+import {
+ BrowserAgentDefinition,
+ type BrowserTaskResultSchema,
+} from './browserAgentDefinition.js';
+import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
+import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+
+/**
+ * Creates a browser agent definition with MCP tools configured.
+ *
+ * This is called when the browser agent is invoked via delegate_to_agent.
+ * The MCP client is created fresh and tools are wrapped for the agent's
+ * isolated registry - NOT registered with the main agent.
+ *
+ * @param config Runtime configuration
+ * @param messageBus Message bus for tool invocations
+ * @param printOutput Optional callback for progress messages
+ * @returns Fully configured LocalAgentDefinition with MCP tools
+ */
+export async function createBrowserAgentDefinition(
+ config: Config,
+ messageBus: MessageBus,
+ printOutput?: (msg: string) => void,
+): Promise<{
+ definition: LocalAgentDefinition;
+ browserManager: BrowserManager;
+}> {
+ debugLogger.log(
+ 'Creating browser agent definition with isolated MCP tools...',
+ );
+
+ // Create and initialize browser manager with isolated MCP client
+ const browserManager = new BrowserManager(config);
+ await browserManager.ensureConnection();
+
+ if (printOutput) {
+ printOutput('Browser connected with isolated MCP client.');
+ }
+
+ // Create declarative tools from dynamically discovered MCP tools
+ // These tools dispatch to browserManager's isolated client
+ const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
+ const availableToolNames = mcpTools.map((t) => t.name);
+
+ // Validate required semantic tools are available
+ const requiredSemanticTools = [
+ 'click',
+ 'fill',
+ 'navigate_page',
+ 'take_snapshot',
+ ];
+ const missingSemanticTools = requiredSemanticTools.filter(
+ (t) => !availableToolNames.includes(t),
+ );
+ if (missingSemanticTools.length > 0) {
+ debugLogger.warn(
+ `Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
+ 'Some browser interactions may not work correctly.',
+ );
+ }
+
+ // Only click_at is strictly required โ text input can use press_key or fill.
+ const requiredVisualTools = ['click_at'];
+ const missingVisualTools = requiredVisualTools.filter(
+ (t) => !availableToolNames.includes(t),
+ );
+
+ // Check whether vision can be enabled; returns undefined if all gates pass.
+ function getVisionDisabledReason(): string | undefined {
+ const browserConfig = config.getBrowserAgentConfig();
+ if (!browserConfig.customConfig.visualModel) {
+ return 'No visualModel configured.';
+ }
+ if (missingVisualTools.length > 0) {
+ return (
+ `Visual tools missing (${missingVisualTools.join(', ')}). ` +
+ `The installed chrome-devtools-mcp version may be too old.`
+ );
+ }
+ const authType = config.getContentGeneratorConfig()?.authType;
+ const blockedAuthTypes = new Set([
+ AuthType.LOGIN_WITH_GOOGLE,
+ AuthType.LEGACY_CLOUD_SHELL,
+ AuthType.COMPUTE_ADC,
+ ]);
+ if (authType && blockedAuthTypes.has(authType)) {
+ return 'Visual agent model not available for current auth type.';
+ }
+ return undefined;
+ }
+
+ const allTools: AnyDeclarativeTool[] = [...mcpTools];
+ const visionDisabledReason = getVisionDisabledReason();
+
+ if (visionDisabledReason) {
+ debugLogger.log(`Vision disabled: ${visionDisabledReason}`);
+ } else {
+ allTools.push(
+ createAnalyzeScreenshotTool(browserManager, config, messageBus),
+ );
+ }
+
+ debugLogger.log(
+ `Created ${allTools.length} tools for browser agent: ` +
+ allTools.map((t) => t.name).join(', '),
+ );
+
+ // Create configured definition with tools
+ // BrowserAgentDefinition is a factory function - call it with config
+ const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
+ const definition: LocalAgentDefinition = {
+ ...baseDefinition,
+ toolConfig: {
+ tools: allTools,
+ },
+ };
+
+ return { definition, browserManager };
+}
+
+/**
+ * Cleans up browser resources after agent execution.
+ *
+ * @param browserManager The browser manager to clean up
+ */
+export async function cleanupBrowserAgent(
+ browserManager: BrowserManager,
+): Promise {
+ try {
+ await browserManager.close();
+ debugLogger.log('Browser agent cleanup complete');
+ } catch (error) {
+ debugLogger.error(
+ `Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+}
diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts
new file mode 100644
index 0000000000..b58a9c409e
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserAgentInvocation } from './browserAgentInvocation.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { AgentInputs } from '../types.js';
+
+// Mock dependencies before imports
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('BrowserAgentInvocation', () => {
+ let mockConfig: Config;
+ let mockMessageBus: MessageBus;
+ let mockParams: AgentInputs;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ sessionMode: 'isolated',
+ },
+ },
+ });
+
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+
+ mockParams = {
+ task: 'Navigate to example.com and click the button',
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should create invocation with params', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ expect(invocation.params).toEqual(mockParams);
+ });
+
+ it('should use browser_agent as default tool name', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ expect(invocation['_toolName']).toBe('browser_agent');
+ });
+
+ it('should use custom tool name if provided', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ 'custom_name',
+ 'Custom Display Name',
+ );
+
+ expect(invocation['_toolName']).toBe('custom_name');
+ expect(invocation['_toolDisplayName']).toBe('Custom Display Name');
+ });
+ });
+
+ describe('getDescription', () => {
+ it('should return description with input summary', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ const description = invocation.getDescription();
+
+ expect(description).toContain('browser agent');
+ expect(description).toContain('task');
+ });
+
+ it('should truncate long input values', () => {
+ const longParams = {
+ task: 'A'.repeat(100),
+ };
+
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ longParams,
+ mockMessageBus,
+ );
+
+ const description = invocation.getDescription();
+
+ // Should be truncated to max length
+ expect(description.length).toBeLessThanOrEqual(200);
+ });
+ });
+
+ describe('toolLocations', () => {
+ it('should return empty array by default', () => {
+ const invocation = new BrowserAgentInvocation(
+ mockConfig,
+ mockParams,
+ mockMessageBus,
+ );
+
+ const locations = invocation.toolLocations();
+
+ expect(locations).toEqual([]);
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts
new file mode 100644
index 0000000000..0de9564c39
--- /dev/null
+++ b/packages/core/src/agents/browser/browserAgentInvocation.ts
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Browser agent invocation that handles async tool setup.
+ *
+ * Unlike regular LocalSubagentInvocation, this invocation:
+ * 1. Uses browserAgentFactory to create definition with MCP tools
+ * 2. Cleans up browser resources after execution
+ *
+ * The MCP tools are only available in the browser agent's isolated registry.
+ */
+
+import type { Config } from '../../config/config.js';
+import { LocalAgentExecutor } from '../local-executor.js';
+import type { AnsiOutput } from '../../utils/terminalSerializer.js';
+import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js';
+import { ToolErrorType } from '../../tools/tool-error.js';
+import type { AgentInputs, SubagentActivityEvent } from '../types.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import {
+ createBrowserAgentDefinition,
+ cleanupBrowserAgent,
+} from './browserAgentFactory.js';
+
+const INPUT_PREVIEW_MAX_LENGTH = 50;
+const DESCRIPTION_MAX_LENGTH = 200;
+
+/**
+ * Browser agent invocation with async tool setup.
+ *
+ * This invocation handles the browser agent's special requirements:
+ * - MCP connection and tool wrapping at invocation time
+ * - Browser cleanup after execution
+ */
+export class BrowserAgentInvocation extends BaseToolInvocation<
+ AgentInputs,
+ ToolResult
+> {
+ constructor(
+ private readonly config: Config,
+ params: AgentInputs,
+ messageBus: MessageBus,
+ _toolName?: string,
+ _toolDisplayName?: string,
+ ) {
+ // Note: BrowserAgentDefinition is a factory function, so we use hardcoded names
+ super(
+ params,
+ messageBus,
+ _toolName ?? 'browser_agent',
+ _toolDisplayName ?? 'Browser Agent',
+ );
+ }
+
+ /**
+ * Returns a concise, human-readable description of the invocation.
+ */
+ getDescription(): string {
+ const inputSummary = Object.entries(this.params)
+ .map(
+ ([key, value]) =>
+ `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
+ )
+ .join(', ');
+
+ const description = `Running browser agent with inputs: { ${inputSummary} }`;
+ return description.slice(0, DESCRIPTION_MAX_LENGTH);
+ }
+
+ /**
+ * Executes the browser agent.
+ *
+ * This method:
+ * 1. Creates browser manager and MCP connection
+ * 2. Wraps MCP tools for the isolated registry
+ * 3. Runs the agent via LocalAgentExecutor
+ * 4. Cleans up browser resources
+ */
+ async execute(
+ signal: AbortSignal,
+ updateOutput?: (output: string | AnsiOutput) => void,
+ ): Promise {
+ let browserManager;
+
+ try {
+ if (updateOutput) {
+ updateOutput('๐ Starting browser agent...\n');
+ }
+
+ // Create definition with MCP tools
+ const printOutput = updateOutput
+ ? (msg: string) => updateOutput(`๐ ${msg}\n`)
+ : undefined;
+
+ const result = await createBrowserAgentDefinition(
+ this.config,
+ this.messageBus,
+ printOutput,
+ );
+ const { definition } = result;
+ browserManager = result.browserManager;
+
+ if (updateOutput) {
+ updateOutput(
+ `๐ Browser connected. Tools: ${definition.toolConfig?.tools.length ?? 0}\n`,
+ );
+ }
+
+ // Create activity callback for streaming output
+ const onActivity = (activity: SubagentActivityEvent): void => {
+ if (!updateOutput) return;
+
+ if (
+ activity.type === 'THOUGHT_CHUNK' &&
+ typeof activity.data['text'] === 'string'
+ ) {
+ updateOutput(`๐๐ญ ${activity.data['text']}`);
+ }
+ };
+
+ // Create and run executor with the configured definition
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ this.config,
+ onActivity,
+ );
+
+ const output = await executor.run(this.params, signal);
+
+ const resultContent = `Browser agent finished.
+Termination Reason: ${output.terminate_reason}
+Result:
+${output.result}`;
+
+ const displayContent = `
+Browser Agent Finished
+
+Termination Reason: ${output.terminate_reason}
+
+Result:
+${output.result}
+`;
+
+ return {
+ llmContent: [{ text: resultContent }],
+ returnDisplay: displayContent,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+
+ return {
+ llmContent: `Browser agent failed. Error: ${errorMessage}`,
+ returnDisplay: `Browser Agent Failed\nError: ${errorMessage}`,
+ error: {
+ message: errorMessage,
+ type: ToolErrorType.EXECUTION_FAILED,
+ },
+ };
+ } finally {
+ // Always cleanup browser resources
+ if (browserManager) {
+ await cleanupBrowserAgent(browserManager);
+ }
+ }
+ }
+}
diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts
new file mode 100644
index 0000000000..6c25181afe
--- /dev/null
+++ b/packages/core/src/agents/browser/browserManager.test.ts
@@ -0,0 +1,414 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserManager } from './browserManager.js';
+import { makeFakeConfig } from '../../test-utils/config.js';
+import type { Config } from '../../config/config.js';
+
+// Mock the MCP SDK
+vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
+ Client: vi.fn().mockImplementation(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn().mockResolvedValue({
+ tools: [
+ { name: 'take_snapshot', description: 'Take a snapshot' },
+ { name: 'click', description: 'Click an element' },
+ { name: 'click_at', description: 'Click at coordinates' },
+ { name: 'take_screenshot', description: 'Take a screenshot' },
+ ],
+ }),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ }),
+ })),
+}));
+
+vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
+ StdioClientTransport: vi.fn().mockImplementation(() => ({
+ close: vi.fn().mockResolvedValue(undefined),
+ stderr: null,
+ })),
+}));
+
+vi.mock('../../utils/debugLogger.js', () => ({
+ debugLogger: {
+ log: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+
+describe('BrowserManager', () => {
+ let mockConfig: Config;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ // Setup mock config
+ mockConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: false,
+ },
+ },
+ });
+
+ // Re-setup Client mock after reset
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn().mockResolvedValue({
+ tools: [
+ { name: 'take_snapshot', description: 'Take a snapshot' },
+ { name: 'click', description: 'Click an element' },
+ { name: 'click_at', description: 'Click at coordinates' },
+ { name: 'take_screenshot', description: 'Take a screenshot' },
+ ],
+ }),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ }),
+ }) as unknown as InstanceType,
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('getRawMcpClient', () => {
+ it('should ensure connection and return raw MCP client', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const client = await manager.getRawMcpClient();
+
+ expect(client).toBeDefined();
+ expect(Client).toHaveBeenCalled();
+ });
+
+ it('should return cached client if already connected', async () => {
+ const manager = new BrowserManager(mockConfig);
+
+ // First call
+ const client1 = await manager.getRawMcpClient();
+
+ // Second call should use cache
+ const client2 = await manager.getRawMcpClient();
+
+ expect(client1).toBe(client2);
+ // Client constructor should only be called once
+ expect(Client).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getDiscoveredTools', () => {
+ it('should return tools discovered from MCP server including visual tools', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const tools = await manager.getDiscoveredTools();
+
+ expect(tools).toHaveLength(4);
+ expect(tools.map((t) => t.name)).toContain('take_snapshot');
+ expect(tools.map((t) => t.name)).toContain('click');
+ expect(tools.map((t) => t.name)).toContain('click_at');
+ expect(tools.map((t) => t.name)).toContain('take_screenshot');
+ });
+ });
+
+ describe('callTool', () => {
+ it('should call tool on MCP client and return result', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const result = await manager.callTool('take_snapshot', { verbose: true });
+
+ expect(result).toEqual({
+ content: [{ type: 'text', text: 'Tool result' }],
+ isError: false,
+ });
+ });
+ });
+
+ describe('MCP connection', () => {
+ it('should spawn npx chrome-devtools-mcp with --experimental-vision (persistent mode by default)', async () => {
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Verify StdioClientTransport was created with correct args
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining([
+ '-y',
+ expect.stringMatching(/chrome-devtools-mcp@/),
+ '--experimental-vision',
+ ]),
+ }),
+ );
+ // Persistent mode should NOT include --isolated or --autoConnect
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).not.toContain('--isolated');
+ expect(args).not.toContain('--autoConnect');
+ // Persistent mode should set the default --userDataDir under ~/.gemini
+ expect(args).toContain('--userDataDir');
+ const userDataDirIndex = args.indexOf('--userDataDir');
+ expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
+ });
+
+ it('should pass headless flag when configured', async () => {
+ const headlessConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ headless: true,
+ },
+ },
+ });
+
+ const manager = new BrowserManager(headlessConfig);
+ await manager.ensureConnection();
+
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining(['--headless']),
+ }),
+ );
+ });
+
+ it('should pass profilePath as --userDataDir when configured', async () => {
+ const profileConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ profilePath: '/path/to/profile',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(profileConfig);
+ await manager.ensureConnection();
+
+ expect(StdioClientTransport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'npx',
+ args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
+ }),
+ );
+ });
+
+ it('should pass --isolated when sessionMode is isolated', async () => {
+ const isolatedConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'isolated',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(isolatedConfig);
+ await manager.ensureConnection();
+
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).toContain('--isolated');
+ expect(args).not.toContain('--autoConnect');
+ });
+
+ it('should pass --autoConnect when sessionMode is existing', async () => {
+ const existingConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'existing',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(existingConfig);
+ await manager.ensureConnection();
+
+ const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
+ ?.args as string[];
+ expect(args).toContain('--autoConnect');
+ expect(args).not.toContain('--isolated');
+ });
+
+ it('should throw actionable error when existing mode connection fails', async () => {
+ // Make the Client mock's connect method reject
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi.fn().mockRejectedValue(new Error('Connection refused')),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const existingConfig = makeFakeConfig({
+ agents: {
+ overrides: {
+ browser_agent: {
+ enabled: true,
+ },
+ },
+ browser: {
+ sessionMode: 'existing',
+ },
+ },
+ });
+
+ const manager = new BrowserManager(existingConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Failed to connect to existing Chrome instance/,
+ );
+ // Create a fresh manager to verify the error message includes remediation steps
+ const manager2 = new BrowserManager(existingConfig);
+ await expect(manager2.ensureConnection()).rejects.toThrow(
+ /chrome:\/\/inspect\/#remote-debugging/,
+ );
+ });
+
+ it('should throw profile-lock remediation when persistent mode hits "already running"', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(
+ new Error(
+ 'Could not connect to Chrome. The browser is already running for the current profile.',
+ ),
+ ),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ // Default config = persistent mode
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Close all Chrome windows using this profile/,
+ );
+ const manager2 = new BrowserManager(mockConfig);
+ await expect(manager2.ensureConnection()).rejects.toThrow(
+ /Set sessionMode to "isolated"/,
+ );
+ });
+
+ it('should throw timeout-specific remediation for persistent mode', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(
+ new Error('Timed out connecting to chrome-devtools-mcp'),
+ ),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /Chrome is not installed/,
+ );
+ });
+
+ it('should include sessionMode in generic fallback error', async () => {
+ vi.mocked(Client).mockImplementation(
+ () =>
+ ({
+ connect: vi
+ .fn()
+ .mockRejectedValue(new Error('Some unexpected error')),
+ close: vi.fn().mockResolvedValue(undefined),
+ listTools: vi.fn(),
+ callTool: vi.fn(),
+ }) as unknown as InstanceType,
+ );
+
+ const manager = new BrowserManager(mockConfig);
+
+ await expect(manager.ensureConnection()).rejects.toThrow(
+ /sessionMode: persistent/,
+ );
+ });
+ });
+
+ describe('MCP isolation', () => {
+ it('should use raw MCP SDK Client, not McpClient wrapper', async () => {
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Verify we're using the raw Client from MCP SDK
+ expect(Client).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'gemini-cli-browser-agent',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should not use McpClientManager from config', async () => {
+ // Spy on config method to verify isolation
+ const getMcpClientManagerSpy = vi.spyOn(
+ mockConfig,
+ 'getMcpClientManager',
+ );
+
+ const manager = new BrowserManager(mockConfig);
+ await manager.ensureConnection();
+
+ // Config's getMcpClientManager should NOT be called
+ // This ensures isolation from main registry
+ expect(getMcpClientManagerSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('close', () => {
+ it('should close MCP connections', async () => {
+ const manager = new BrowserManager(mockConfig);
+ const client = await manager.getRawMcpClient();
+
+ await manager.close();
+
+ expect(client.close).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts
new file mode 100644
index 0000000000..205eb11a1f
--- /dev/null
+++ b/packages/core/src/agents/browser/browserManager.ts
@@ -0,0 +1,436 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Manages browser lifecycle for the Browser Agent.
+ *
+ * Handles:
+ * - Browser management via chrome-devtools-mcp with --isolated mode
+ * - CDP connection via raw MCP SDK Client (NOT registered in main registry)
+ * - Visual tools via --experimental-vision flag
+ *
+ * IMPORTANT: The MCP client here is ISOLATED from the main agent's tool registry.
+ * Tools discovered from chrome-devtools-mcp are NOT registered in the main registry.
+ * They are wrapped as DeclarativeTools and passed directly to the browser agent.
+ */
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import type { Config } from '../../config/config.js';
+import { Storage } from '../../config/storage.js';
+import * as path from 'node:path';
+
+// Pin chrome-devtools-mcp version for reproducibility.
+const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
+
+// Default browser profile directory name within ~/.gemini/
+const BROWSER_PROFILE_DIR = 'cli-browser-profile';
+
+// Default timeout for MCP operations
+const MCP_TIMEOUT_MS = 60_000;
+
+/**
+ * Content item from an MCP tool call response.
+ * Can be text or image (for take_screenshot).
+ */
+export interface McpContentItem {
+ type: 'text' | 'image';
+ text?: string;
+ /** Base64-encoded image data (for type='image') */
+ data?: string;
+ /** MIME type of the image (e.g., 'image/png') */
+ mimeType?: string;
+}
+
+/**
+ * Result from an MCP tool call.
+ */
+export interface McpToolCallResult {
+ content?: McpContentItem[];
+ isError?: boolean;
+}
+
+/**
+ * Manages browser lifecycle and ISOLATED MCP client for the Browser Agent.
+ *
+ * The browser is launched and managed by chrome-devtools-mcp in --isolated mode.
+ * Visual tools (click_at, etc.) are enabled via --experimental-vision flag.
+ *
+ * Key isolation property: The MCP client here does NOT register tools
+ * in the main ToolRegistry. Tools are kept local to the browser agent.
+ */
+export class BrowserManager {
+ // Raw MCP SDK Client - NOT the wrapper McpClient
+ private rawMcpClient: Client | undefined;
+ private mcpTransport: StdioClientTransport | undefined;
+ private discoveredTools: McpTool[] = [];
+
+ constructor(private config: Config) {}
+
+ /**
+ * Gets the raw MCP SDK Client for direct tool calls.
+ * This client is ISOLATED from the main tool registry.
+ */
+ async getRawMcpClient(): Promise {
+ if (this.rawMcpClient) {
+ return this.rawMcpClient;
+ }
+ await this.ensureConnection();
+ if (!this.rawMcpClient) {
+ throw new Error('Failed to initialize chrome-devtools MCP client');
+ }
+ return this.rawMcpClient;
+ }
+
+ /**
+ * Gets the tool definitions discovered from the MCP server.
+ * These are dynamically fetched from chrome-devtools-mcp.
+ */
+ async getDiscoveredTools(): Promise {
+ await this.ensureConnection();
+ return this.discoveredTools;
+ }
+
+ /**
+ * Calls a tool on the MCP server.
+ *
+ * @param toolName The name of the tool to call
+ * @param args Arguments to pass to the tool
+ * @param signal Optional AbortSignal to cancel the call
+ * @returns The result from the MCP server
+ */
+ async callTool(
+ toolName: string,
+ args: Record,
+ signal?: AbortSignal,
+ ): Promise {
+ if (signal?.aborted) {
+ throw signal.reason ?? new Error('Operation cancelled');
+ }
+
+ const client = await this.getRawMcpClient();
+ const callPromise = client.callTool(
+ { name: toolName, arguments: args },
+ undefined,
+ { timeout: MCP_TIMEOUT_MS },
+ );
+
+ // If no signal, just await directly
+ if (!signal) {
+ return this.toResult(await callPromise);
+ }
+
+ // Race the call against the abort signal
+ let onAbort: (() => void) | undefined;
+ try {
+ const result = await Promise.race([
+ callPromise,
+ new Promise((_resolve, reject) => {
+ onAbort = () =>
+ reject(signal.reason ?? new Error('Operation cancelled'));
+ signal.addEventListener('abort', onAbort, { once: true });
+ }),
+ ]);
+ return this.toResult(result);
+ } finally {
+ if (onAbort) {
+ signal.removeEventListener('abort', onAbort);
+ }
+ }
+ }
+
+ /**
+ * Safely maps a raw MCP SDK callTool response to our typed McpToolCallResult
+ * without using unsafe type assertions.
+ */
+ private toResult(
+ raw: Awaited>,
+ ): McpToolCallResult {
+ return {
+ content: Array.isArray(raw.content)
+ ? raw.content.map(
+ (item: {
+ type?: string;
+ text?: string;
+ data?: string;
+ mimeType?: string;
+ }) => ({
+ type: item.type === 'image' ? 'image' : 'text',
+ text: item.text,
+ data: item.data,
+ mimeType: item.mimeType,
+ }),
+ )
+ : undefined,
+ isError: raw.isError === true,
+ };
+ }
+
+ /**
+ * Ensures browser and MCP client are connected.
+ */
+ async ensureConnection(): Promise {
+ if (this.rawMcpClient) {
+ return;
+ }
+ await this.connectMcp();
+ }
+
+ /**
+ * Closes browser and cleans up connections.
+ * The browser process is managed by chrome-devtools-mcp, so closing
+ * the transport will terminate the browser.
+ */
+ async close(): Promise {
+ // Close MCP client first
+ if (this.rawMcpClient) {
+ try {
+ await this.rawMcpClient.close();
+ } catch (error) {
+ debugLogger.error(
+ `Error closing MCP client: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ this.rawMcpClient = undefined;
+ }
+
+ // Close transport (this terminates the npx process and browser)
+ if (this.mcpTransport) {
+ try {
+ await this.mcpTransport.close();
+ } catch (error) {
+ debugLogger.error(
+ `Error closing MCP transport: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ this.mcpTransport = undefined;
+ }
+
+ this.discoveredTools = [];
+ }
+
+ /**
+ * Connects to chrome-devtools-mcp which manages the browser process.
+ *
+ * Spawns npx chrome-devtools-mcp with:
+ * - --isolated: Manages its own browser instance
+ * - --experimental-vision: Enables visual tools (click_at, etc.)
+ *
+ * IMPORTANT: This does NOT use McpClientManager and does NOT register
+ * tools in the main ToolRegistry. The connection is isolated to this
+ * BrowserManager instance.
+ */
+ private async connectMcp(): Promise {
+ debugLogger.log('Connecting isolated MCP client to chrome-devtools-mcp...');
+
+ // Create raw MCP SDK Client (not the wrapper McpClient)
+ this.rawMcpClient = new Client(
+ {
+ name: 'gemini-cli-browser-agent',
+ version: '1.0.0',
+ },
+ {
+ capabilities: {},
+ },
+ );
+
+ // Build args for chrome-devtools-mcp
+ const browserConfig = this.config.getBrowserAgentConfig();
+ const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
+
+ const mcpArgs = [
+ '-y',
+ `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`,
+ '--experimental-vision',
+ ];
+
+ // Session mode determines how the browser is managed:
+ // - "isolated": Temp profile, cleaned up after session (--isolated)
+ // - "persistent": Persistent profile at ~/.gemini/cli-browser-profile/ (default)
+ // - "existing": Connect to already-running Chrome (--autoConnect, requires
+ // remote debugging enabled at chrome://inspect/#remote-debugging)
+ if (sessionMode === 'isolated') {
+ mcpArgs.push('--isolated');
+ } else if (sessionMode === 'existing') {
+ mcpArgs.push('--autoConnect');
+ }
+
+ // Add optional settings from config
+ if (browserConfig.customConfig.headless) {
+ mcpArgs.push('--headless');
+ }
+ if (browserConfig.customConfig.profilePath) {
+ mcpArgs.push('--userDataDir', browserConfig.customConfig.profilePath);
+ } else if (sessionMode === 'persistent') {
+ // Default persistent profile lives under ~/.gemini/cli-browser-profile
+ const defaultProfilePath = path.join(
+ Storage.getGlobalGeminiDir(),
+ BROWSER_PROFILE_DIR,
+ );
+ mcpArgs.push('--userDataDir', defaultProfilePath);
+ }
+
+ debugLogger.log(
+ `Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
+ );
+
+ // Create stdio transport to npx chrome-devtools-mcp.
+ // stderr is piped (not inherited) to prevent MCP server banners and
+ // warnings from corrupting the UI in alternate buffer mode.
+ this.mcpTransport = new StdioClientTransport({
+ command: 'npx',
+ args: mcpArgs,
+ stderr: 'pipe',
+ });
+
+ // Forward piped stderr to debugLogger so it's visible with --debug.
+ const stderrStream = this.mcpTransport.stderr;
+ if (stderrStream) {
+ stderrStream.on('data', (chunk: Buffer) => {
+ debugLogger.log(
+ `[chrome-devtools-mcp stderr] ${chunk.toString().trimEnd()}`,
+ );
+ });
+ }
+
+ this.mcpTransport.onclose = () => {
+ debugLogger.error(
+ 'chrome-devtools-mcp transport closed unexpectedly. ' +
+ 'The MCP server process may have crashed.',
+ );
+ this.rawMcpClient = undefined;
+ };
+ this.mcpTransport.onerror = (error: Error) => {
+ debugLogger.error(
+ `chrome-devtools-mcp transport error: ${error.message}`,
+ );
+ };
+
+ // Connect to MCP server โ use a shorter timeout for 'existing' mode
+ // since it should connect quickly if remote debugging is enabled.
+ const connectTimeoutMs =
+ sessionMode === 'existing' ? 15_000 : MCP_TIMEOUT_MS;
+
+ let timeoutId: ReturnType | undefined;
+ try {
+ await Promise.race([
+ (async () => {
+ await this.rawMcpClient!.connect(this.mcpTransport!);
+ debugLogger.log('MCP client connected to chrome-devtools-mcp');
+ await this.discoverTools();
+ })(),
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Timed out connecting to chrome-devtools-mcp (${connectTimeoutMs}ms)`,
+ ),
+ ),
+ connectTimeoutMs,
+ );
+ }),
+ ]);
+ } catch (error) {
+ await this.close();
+
+ // Provide error-specific, session-mode-aware remediation
+ throw this.createConnectionError(
+ error instanceof Error ? error.message : String(error),
+ sessionMode,
+ );
+ } finally {
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
+
+ /**
+ * Creates an Error with context-specific remediation based on the actual
+ * error message and the current sessionMode.
+ */
+ private createConnectionError(message: string, sessionMode: string): Error {
+ const lowerMessage = message.toLowerCase();
+
+ // "already running for the current profile" โ persistent mode profile lock
+ if (lowerMessage.includes('already running')) {
+ if (sessionMode === 'persistent' || sessionMode === 'isolated') {
+ return new Error(
+ `Could not connect to Chrome: ${message}\n\n` +
+ `The Chrome profile is locked by another running instance.\n` +
+ `To fix this:\n` +
+ ` 1. Close all Chrome windows using this profile, OR\n` +
+ ` 2. Set sessionMode to "isolated" in settings.json to use a temporary profile, OR\n` +
+ ` 3. Set profilePath in settings.json to use a different profile directory`,
+ );
+ }
+ // existing mode โ shouldn't normally hit this, but handle gracefully
+ return new Error(
+ `Could not connect to Chrome: ${message}\n\n` +
+ `The Chrome profile is locked.\n` +
+ `Close other Chrome instances and try again.`,
+ );
+ }
+
+ // Timeout errors
+ if (lowerMessage.includes('timed out')) {
+ if (sessionMode === 'existing') {
+ return new Error(
+ `Timed out connecting to Chrome: ${message}\n\n` +
+ `To use sessionMode "existing", you must:\n` +
+ ` 1. Open Chrome (version 144+)\n` +
+ ` 2. Navigate to chrome://inspect/#remote-debugging\n` +
+ ` 3. Enable remote debugging\n\n` +
+ `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`,
+ );
+ }
+ return new Error(
+ `Timed out connecting to Chrome: ${message}\n\n` +
+ `Possible causes:\n` +
+ ` 1. Chrome is not installed or not in PATH\n` +
+ ` 2. npx cannot download chrome-devtools-mcp (check network/proxy)\n` +
+ ` 3. Chrome failed to start (try setting headless: true in settings.json)`,
+ );
+ }
+
+ // Generic "existing" mode failures (connection refused, etc.)
+ if (sessionMode === 'existing') {
+ return new Error(
+ `Failed to connect to existing Chrome instance: ${message}\n\n` +
+ `To use sessionMode "existing", you must:\n` +
+ ` 1. Open Chrome (version 144+)\n` +
+ ` 2. Navigate to chrome://inspect/#remote-debugging\n` +
+ ` 3. Enable remote debugging\n\n` +
+ `Alternatively, set sessionMode to "persistent" (default) in settings.json to launch a dedicated browser.`,
+ );
+ }
+
+ // Generic fallback โ include sessionMode for debugging context
+ return new Error(
+ `Failed to connect to Chrome (sessionMode: ${sessionMode}): ${message}`,
+ );
+ }
+
+ /**
+ * Discovers tools from the connected MCP server.
+ */
+ private async discoverTools(): Promise {
+ if (!this.rawMcpClient) {
+ throw new Error('MCP client not connected');
+ }
+
+ const response = await this.rawMcpClient.listTools();
+ this.discoveredTools = response.tools;
+
+ debugLogger.log(
+ `Discovered ${this.discoveredTools.length} tools from chrome-devtools-mcp: ` +
+ this.discoveredTools.map((t) => t.name).join(', '),
+ );
+ }
+}
diff --git a/packages/core/src/agents/browser/mcpToolWrapper.test.ts b/packages/core/src/agents/browser/mcpToolWrapper.test.ts
new file mode 100644
index 0000000000..a99ff4943c
--- /dev/null
+++ b/packages/core/src/agents/browser/mcpToolWrapper.test.ts
@@ -0,0 +1,196 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+
+describe('mcpToolWrapper', () => {
+ let mockBrowserManager: BrowserManager;
+ let mockMessageBus: MessageBus;
+ let mockMcpTools: McpTool[];
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ // Setup mock MCP tools discovered from server
+ mockMcpTools = [
+ {
+ name: 'take_snapshot',
+ description: 'Take a snapshot of the page accessibility tree',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ verbose: { type: 'boolean', description: 'Include details' },
+ },
+ },
+ },
+ {
+ name: 'click',
+ description: 'Click on an element by uid',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: { type: 'string', description: 'Element uid' },
+ },
+ required: ['uid'],
+ },
+ },
+ ];
+
+ // Setup mock browser manager
+ mockBrowserManager = {
+ getDiscoveredTools: vi.fn().mockResolvedValue(mockMcpTools),
+ callTool: vi.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'Tool result' }],
+ } as McpToolCallResult),
+ } as unknown as BrowserManager;
+
+ // Setup mock message bus
+ mockMessageBus = {
+ publish: vi.fn().mockResolvedValue(undefined),
+ subscribe: vi.fn(),
+ unsubscribe: vi.fn(),
+ } as unknown as MessageBus;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('createMcpDeclarativeTools', () => {
+ it('should create declarative tools from discovered MCP tools', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ expect(tools).toHaveLength(3);
+ expect(tools[0].name).toBe('take_snapshot');
+ expect(tools[1].name).toBe('click');
+ expect(tools[2].name).toBe('type_text');
+ });
+
+ it('should return tools with correct description', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ // Descriptions include augmented hints, so we check they contain the original
+ expect(tools[0].description).toContain(
+ 'Take a snapshot of the page accessibility tree',
+ );
+ expect(tools[1].description).toContain('Click on an element by uid');
+ });
+
+ it('should return tools with proper FunctionDeclaration schema', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const schema = tools[0].schema;
+ expect(schema.name).toBe('take_snapshot');
+ expect(schema.parametersJsonSchema).toBeDefined();
+ });
+ });
+
+ describe('McpDeclarativeTool.build', () => {
+ it('should create invocation that can be executed', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({ verbose: true });
+
+ expect(invocation).toBeDefined();
+ expect(invocation.params).toEqual({ verbose: true });
+ });
+
+ it('should return invocation with correct description', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({});
+
+ expect(invocation.getDescription()).toContain('take_snapshot');
+ });
+ });
+
+ describe('McpToolInvocation.execute', () => {
+ it('should call browserManager.callTool with correct params', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[1].build({ uid: 'elem-123' });
+ await invocation.execute(new AbortController().signal);
+
+ expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
+ 'click',
+ {
+ uid: 'elem-123',
+ },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('should return success result from MCP tool', async () => {
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({ verbose: true });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.llmContent).toBe('Tool result');
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should handle MCP tool errors', async () => {
+ vi.mocked(mockBrowserManager.callTool).mockResolvedValue({
+ content: [{ type: 'text', text: 'Element not found' }],
+ isError: true,
+ } as McpToolCallResult);
+
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[1].build({ uid: 'invalid' });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toBe('Element not found');
+ });
+
+ it('should handle exceptions during tool call', async () => {
+ vi.mocked(mockBrowserManager.callTool).mockRejectedValue(
+ new Error('Connection lost'),
+ );
+
+ const tools = await createMcpDeclarativeTools(
+ mockBrowserManager,
+ mockMessageBus,
+ );
+
+ const invocation = tools[0].build({});
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toBe('Connection lost');
+ });
+ });
+});
diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts
new file mode 100644
index 0000000000..1838a01b42
--- /dev/null
+++ b/packages/core/src/agents/browser/mcpToolWrapper.ts
@@ -0,0 +1,545 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Creates DeclarativeTool classes for MCP tools.
+ *
+ * These tools are ONLY registered in the browser agent's isolated ToolRegistry,
+ * NOT in the main agent's registry. They dispatch to the BrowserManager's
+ * isolated MCP client directly.
+ *
+ * Tool definitions are dynamically discovered from chrome-devtools-mcp
+ * at runtime, not hardcoded.
+ */
+
+import type { FunctionDeclaration } from '@google/genai';
+import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+import {
+ type ToolConfirmationOutcome,
+ DeclarativeTool,
+ BaseToolInvocation,
+ Kind,
+ type ToolResult,
+ type ToolInvocation,
+ type ToolCallConfirmationDetails,
+ type PolicyUpdateOptions,
+} from '../../tools/tools.js';
+import type { MessageBus } from '../../confirmation-bus/message-bus.js';
+import type { BrowserManager, McpToolCallResult } from './browserManager.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+
+/**
+ * Tool invocation that dispatches to BrowserManager's isolated MCP client.
+ */
+class McpToolInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly toolName: string,
+ params: Record,
+ messageBus: MessageBus,
+ ) {
+ super(params, messageBus, toolName, toolName);
+ }
+
+ getDescription(): string {
+ return `Calling MCP tool: ${this.toolName}`;
+ }
+
+ protected override async getConfirmationDetails(
+ _abortSignal: AbortSignal,
+ ): Promise {
+ if (!this.messageBus) {
+ return false;
+ }
+
+ return {
+ type: 'mcp',
+ title: `Confirm MCP Tool: ${this.toolName}`,
+ serverName: 'browser-agent',
+ toolName: this.toolName,
+ toolDisplayName: this.toolName,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ await this.publishPolicyUpdate(outcome);
+ },
+ };
+ }
+
+ protected override getPolicyUpdateOptions(
+ _outcome: ToolConfirmationOutcome,
+ ): PolicyUpdateOptions | undefined {
+ return {
+ mcpName: 'browser-agent',
+ };
+ }
+
+ async execute(signal: AbortSignal): Promise {
+ try {
+ const callToolPromise = this.browserManager.callTool(
+ this.toolName,
+ this.params,
+ signal,
+ );
+
+ const result: McpToolCallResult = await callToolPromise;
+
+ // Extract text content from MCP response
+ let textContent = '';
+ if (result.content && Array.isArray(result.content)) {
+ textContent = result.content
+ .filter((c) => c.type === 'text' && c.text)
+ .map((c) => c.text)
+ .join('\n');
+ }
+
+ // Post-process to add contextual hints for common error patterns
+ const processedContent = postProcessToolResult(
+ this.toolName,
+ textContent,
+ );
+
+ if (result.isError) {
+ return {
+ llmContent: `Error: ${processedContent}`,
+ returnDisplay: `Error: ${processedContent}`,
+ error: { message: textContent },
+ };
+ }
+
+ return {
+ llmContent: processedContent || 'Tool executed successfully.',
+ returnDisplay: processedContent || 'Tool executed successfully.',
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+
+ // Chrome connection errors are fatal โ re-throw to terminate the agent
+ // immediately instead of returning a result the LLM would retry.
+ if (errorMsg.includes('Could not connect to Chrome')) {
+ throw error;
+ }
+
+ debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`);
+ return {
+ llmContent: `Error: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ error: { message: errorMsg },
+ };
+ }
+ }
+}
+
+/**
+ * Composite tool invocation that types a full string by calling press_key
+ * for each character internally, avoiding N model round-trips.
+ */
+class TypeTextInvocation extends BaseToolInvocation<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ private readonly text: string,
+ private readonly submitKey: string | undefined,
+ messageBus: MessageBus,
+ ) {
+ super({ text, submitKey }, messageBus, 'type_text', 'type_text');
+ }
+
+ getDescription(): string {
+ const preview = `"${this.text.substring(0, 50)}${this.text.length > 50 ? '...' : ''}"`;
+ return this.submitKey
+ ? `type_text: ${preview} + ${this.submitKey}`
+ : `type_text: ${preview}`;
+ }
+
+ protected override async getConfirmationDetails(
+ _abortSignal: AbortSignal,
+ ): Promise {
+ if (!this.messageBus) {
+ return false;
+ }
+
+ return {
+ type: 'mcp',
+ title: `Confirm Tool: type_text`,
+ serverName: 'browser-agent',
+ toolName: 'type_text',
+ toolDisplayName: 'type_text',
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ await this.publishPolicyUpdate(outcome);
+ },
+ };
+ }
+
+ protected override getPolicyUpdateOptions(
+ _outcome: ToolConfirmationOutcome,
+ ): PolicyUpdateOptions | undefined {
+ return {
+ mcpName: 'browser-agent',
+ };
+ }
+
+ override async execute(signal: AbortSignal): Promise {
+ try {
+ if (signal.aborted) {
+ return {
+ llmContent: 'Error: Operation cancelled before typing started.',
+ returnDisplay: 'Operation cancelled before typing started.',
+ error: { message: 'Operation cancelled' },
+ };
+ }
+
+ await this.typeCharByChar(signal);
+
+ // Optionally press a submit key (Enter, Tab, etc.) after typing
+ if (this.submitKey && !signal.aborted) {
+ const keyResult = await this.browserManager.callTool(
+ 'press_key',
+ { key: this.submitKey },
+ signal,
+ );
+ if (keyResult.isError) {
+ const errText = this.extractErrorText(keyResult);
+ debugLogger.warn(
+ `type_text: submitKey("${this.submitKey}") failed: ${errText}`,
+ );
+ }
+ }
+
+ const summary = this.submitKey
+ ? `Successfully typed "${this.text}" and pressed ${this.submitKey}`
+ : `Successfully typed "${this.text}"`;
+
+ return {
+ llmContent: summary,
+ returnDisplay: summary,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+
+ // Chrome connection errors are fatal
+ if (errorMsg.includes('Could not connect to Chrome')) {
+ throw error;
+ }
+
+ debugLogger.error(`type_text failed: ${errorMsg}`);
+ return {
+ llmContent: `Error: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ error: { message: errorMsg },
+ };
+ }
+ }
+
+ /** Types each character via individual press_key MCP calls. */
+ private async typeCharByChar(signal: AbortSignal): Promise {
+ const chars = [...this.text]; // Handle Unicode correctly
+ for (const char of chars) {
+ if (signal.aborted) return;
+
+ // Map special characters to key names
+ const key = char === ' ' ? 'Space' : char;
+ const result = await this.browserManager.callTool(
+ 'press_key',
+ { key },
+ signal,
+ );
+
+ if (result.isError) {
+ debugLogger.warn(
+ `type_text: press_key("${key}") failed: ${this.extractErrorText(result)}`,
+ );
+ }
+ }
+ }
+
+ /** Extract error text from an MCP tool result. */
+ private extractErrorText(result: McpToolCallResult): string {
+ return (
+ result.content
+ ?.filter(
+ (c: { type: string; text?: string }) => c.type === 'text' && c.text,
+ )
+ .map((c: { type: string; text?: string }) => c.text)
+ .join('\n') || 'Unknown error'
+ );
+ }
+}
+
+/**
+ * DeclarativeTool wrapper for an MCP tool.
+ */
+class McpDeclarativeTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ name: string,
+ description: string,
+ parameterSchema: unknown,
+ messageBus: MessageBus,
+ ) {
+ super(
+ name,
+ name,
+ description,
+ Kind.Other,
+ parameterSchema,
+ messageBus,
+ /* isOutputMarkdown */ true,
+ /* canUpdateOutput */ false,
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ return new McpToolInvocation(
+ this.browserManager,
+ this.name,
+ params,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * DeclarativeTool for the custom type_text composite tool.
+ */
+class TypeTextDeclarativeTool extends DeclarativeTool<
+ Record,
+ ToolResult
+> {
+ constructor(
+ private readonly browserManager: BrowserManager,
+ messageBus: MessageBus,
+ ) {
+ super(
+ 'type_text',
+ 'type_text',
+ 'Types a full text string into the currently focused element. ' +
+ 'Much faster than calling press_key for each character individually. ' +
+ 'Use this to enter text into form fields, search boxes, spreadsheet cells, or any focused input. ' +
+ 'The element must already be focused (e.g., after a click). ' +
+ 'Use submitKey to press a key after typing (e.g., submitKey="Enter" to submit a form or confirm a value, submitKey="Tab" to move to the next field).',
+ Kind.Other,
+ {
+ type: 'object',
+ properties: {
+ text: {
+ type: 'string',
+ description: 'The text to type into the focused element.',
+ },
+ submitKey: {
+ type: 'string',
+ description:
+ 'Optional key to press after typing (e.g., "Enter", "Tab", "Escape"). ' +
+ 'Useful for submitting form fields or moving to the next cell in a spreadsheet.',
+ },
+ },
+ required: ['text'],
+ },
+ messageBus,
+ /* isOutputMarkdown */ true,
+ /* canUpdateOutput */ false,
+ );
+ }
+
+ build(
+ params: Record,
+ ): ToolInvocation, ToolResult> {
+ const submitKey =
+ typeof params['submitKey'] === 'string' && params['submitKey']
+ ? params['submitKey']
+ : undefined;
+ return new TypeTextInvocation(
+ this.browserManager,
+ String(params['text'] ?? ''),
+ submitKey,
+ this.messageBus,
+ );
+ }
+}
+
+/**
+ * Creates DeclarativeTool instances from dynamically discovered MCP tools,
+ * plus custom composite tools (like type_text).
+ *
+ * These tools are registered in the browser agent's isolated ToolRegistry,
+ * NOT in the main agent's registry.
+ *
+ * Tool definitions are fetched dynamically from the MCP server at runtime.
+ *
+ * @param browserManager The browser manager with isolated MCP client
+ * @param messageBus Message bus for tool invocations
+ * @returns Array of DeclarativeTools that dispatch to the isolated MCP client
+ */
+export async function createMcpDeclarativeTools(
+ browserManager: BrowserManager,
+ messageBus: MessageBus,
+): Promise> {
+ // Get dynamically discovered tools from the MCP server
+ const mcpTools = await browserManager.getDiscoveredTools();
+
+ debugLogger.log(
+ `Creating ${mcpTools.length} declarative tools for browser agent`,
+ );
+
+ const tools: Array =
+ mcpTools.map((mcpTool) => {
+ const schema = convertMcpToolToFunctionDeclaration(mcpTool);
+ // Augment description with uid-context hints
+ const augmentedDescription = augmentToolDescription(
+ mcpTool.name,
+ mcpTool.description ?? '',
+ );
+ return new McpDeclarativeTool(
+ browserManager,
+ mcpTool.name,
+ augmentedDescription,
+ schema.parametersJsonSchema,
+ messageBus,
+ );
+ });
+
+ // Add custom composite tools
+ tools.push(new TypeTextDeclarativeTool(browserManager, messageBus));
+
+ debugLogger.log(
+ `Total tools registered: ${tools.length} (${mcpTools.length} MCP + 1 custom)`,
+ );
+
+ return tools;
+}
+
+/**
+ * Converts MCP tool definition to Gemini FunctionDeclaration.
+ */
+function convertMcpToolToFunctionDeclaration(
+ mcpTool: McpTool,
+): FunctionDeclaration {
+ // MCP tool inputSchema is a JSON Schema object
+ // We pass it directly as parametersJsonSchema
+ return {
+ name: mcpTool.name,
+ description: mcpTool.description ?? '',
+ parametersJsonSchema: mcpTool.inputSchema ?? {
+ type: 'object',
+ properties: {},
+ },
+ };
+}
+
+/**
+ * Augments MCP tool descriptions with usage guidance.
+ * Adds semantic hints and usage rules directly in tool descriptions
+ * so the model makes correct tool choices without system prompt overhead.
+ *
+ * Actual chrome-devtools-mcp tools:
+ * Input: click, drag, fill, fill_form, handle_dialog, hover, press_key, upload_file
+ * Navigation: close_page, list_pages, navigate_page, new_page, select_page, wait_for
+ * Emulation: emulate, resize_page
+ * Performance: performance_analyze_insight, performance_start_trace, performance_stop_trace
+ * Network: get_network_request, list_network_requests
+ * Debugging: evaluate_script, get_console_message, list_console_messages, take_screenshot, take_snapshot
+ * Vision (--experimental-vision): click_at, analyze_screenshot
+ */
+function augmentToolDescription(toolName: string, description: string): string {
+ // More-specific keys MUST come before shorter keys to prevent
+ // partial matching from short-circuiting (e.g., fill_form before fill).
+ const hints: Record = {
+ fill_form:
+ ' Fills multiple standard HTML form fields at once. Same limitations as fill โ does not work on canvas/custom widgets.',
+ fill: ' Fills standard HTML form fields (,