Compare commits

...

35 Commits

Author SHA1 Message Date
Your Name 4874bedbef Modify UserSimulator prompt to prefer 'Allow for this session' for execution prompts 2026-04-20 23:59:04 +00:00
Hadi Minooei d6a1864961 feat: add option to disable streaming in CLI 2026-04-20 12:24:27 -07:00
Your Name 1c963345a4 Fix regex gap in UserSimulator to handle 'Xm' timer format 2026-04-20 18:59:15 +00:00
Your Name 268970671a Improve screen normalization in UserSimulator to prevent polling loops 2026-04-20 18:52:05 +00:00
Your Name e879100f07 Fix race condition in terminal rendering in UserSimulator 2026-04-20 17:09:23 +00:00
Your Name 7a71df31ff Add debug logging to submission flow in InputPrompt 2026-04-20 16:02:34 +00:00
Your Name 76a5e4f87f Add debug logging to handleFinalSubmit in AppContainer 2026-04-20 16:02:31 +00:00
Your Name 10ade0306b Add debug logging to usePlanContent in ExitPlanModeDialog 2026-04-20 16:01:35 +00:00
Your Name 73e319324f Fix keypress handling when simulateUser is enabled and add debug logging 2026-04-20 16:01:34 +00:00
Your Name f0b49261a5 Add debug logging for keypresses in TextInput 2026-04-20 16:01:33 +00:00
Your Name 0e3d2fb880 Add debug logging for input keys in InputPrompt 2026-04-20 16:01:32 +00:00
Hadi Minooei bb8565ee9d fix(build): handle file exists error in build_package 2026-04-16 22:10:27 -07:00
Hadi Minooei bfbdae8e3a Merge remote-tracking branch 'origin/main' into feature/simulator-knowledge-update
# Conflicts:
#	package-lock.json
#	package.json
#	packages/cli/src/interactiveCli.tsx
#	packages/core/src/telemetry/llmRole.ts
2026-04-16 22:05:57 -07:00
Hadi Minooei 40f9db30ce feat(cli): add user simulator, docker support, and external knowledge source handling
- Added UserSimulator service for automated interactions.

- Added Docker simulation script and documentation.

- Supported --knowledge-source flag defaulting to ~/.agents/kb.md.
2026-04-16 19:16:39 -07:00
Christian Gunderman 22fb83320e Reduce blank lines. (#25563) 2026-04-16 23:54:57 +00:00
Matt Van Horn 63e4bb985b feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first (#25427)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-16 22:21:24 +00:00
Gal Zahavi fe890429a4 fix(core): allow explicit write permissions to override governance file protections in sandboxes (#25338) 2026-04-16 21:18:09 +00:00
Nicolas Ouellet-Payeur 655165cde4 docs(policy): mention that workspace policies are broken (#24367)
Co-authored-by: Nicolas Ouellet-Payeur <nicolaso@chromium.org>
2026-04-16 21:04:46 +00:00
Sam Roberts 17557b1aeb feat(core): add .mdx support to get-internal-docs tool (#25090) 2026-04-16 20:58:34 +00:00
Jason Matthew Suhari 9600da2c8f fix(cli): reset plan session state on /clear (#25515) 2026-04-16 19:20:36 +00:00
Abhi 2b6dab6136 fix(extensions): fix bundling for examples (#25542) 2026-04-16 19:11:03 +00:00
jackyliuxx ac9025e9fc Use OSC 777 for terminal notifications (#25300) 2026-04-16 18:45:54 +00:00
Sandy Tao fafe3e35d2 fix(evals): add typecheck coverage for evals, integration-tests, and memory-tests (#25480) 2026-04-16 18:20:27 +00:00
ruomeng f16f1cced3 feat(core): add tools to list and read MCP resources (#25395) 2026-04-16 17:57:43 +00:00
ruomeng 963631a3d4 feat(cli): provide default post-submit prompt for skill command (#25327) 2026-04-16 17:56:20 +00:00
Sandy Tao 6d7974f1ef feat(core): integrate skill-creator into skill extraction agent (#25421) 2026-04-16 01:38:59 +00:00
gemini-cli-robot 34a9d6e421 Changelog for v0.38.1 (#25476)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-04-16 00:06:43 +00:00
Sandy Tao 00b7781c3c fix(evals): update eval tests for invoke_agent telemetry and project-scoped memory (#25502) 2026-04-15 23:14:12 +00:00
gemini-cli-robot e827cfdf83 Changelog for v0.38.0 (#25470)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-15 20:02:53 +00:00
Jason Matthew Suhari cb35ee6710 fix(cli): clean up slash command IDE listeners (#24397)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-15 19:41:50 +00:00
Christopher Thomas cb289e0724 fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL (#25357) 2026-04-15 19:38:38 +00:00
Z1xus c5ad0abb5d feat: add github colorblind themes (#15504)
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-04-15 19:23:50 +00:00
anj-s e664cc20fe test(evals): add subagent delegation evaluation tests (#24619) 2026-04-15 19:07:29 +00:00
Sandy Tao 485f3d92d8 improve(core): require recurrence evidence before extracting skills (#25147) 2026-04-15 18:45:31 +00:00
cynthialong0-0 5333e5ab20 feat(test): add large conversation scenario for performance test (#25331) 2026-04-15 18:08:43 +00:00
129 changed files with 6508 additions and 1322 deletions
+6
View File
@@ -102,6 +102,12 @@ jobs:
- name: 'Run yamllint'
run: 'node scripts/lint.js --yamllint'
- name: 'Build project for typecheck'
run: 'npm run build'
- name: 'Run typecheck'
run: 'npm run typecheck'
- name: 'Run Prettier'
run: 'node scripts/lint.js --prettier'
+111
View File
@@ -0,0 +1,111 @@
# Running User Simulation in Docker with External Knowledge Source
This guide explains how to run the User Simulator in a Docker environment while
mounting an external knowledge base. This setup allows the simulator to "learn"
from its interactions and persist that knowledge back to your host machine.
We have provided an automated script that handles the entire setup, execution,
and cleanup process.
## Prerequisites
- **Docker** installed and running.
- **Gemini API Key** (standard `AIza...` key).
- Local checkout of the `gemini-cli` repository.
## Execution via Automation Script (Recommended)
The easiest and most reliable way to run the simulation is using the provided
bash script. This script automatically:
1. Creates a uniquely timestamped workspace folder on your host.
2. Generates a global `settings.json` file to natively bypass the CLI's
interactive Folder Trust and Authentication dialogs.
3. Builds the sandbox image from your current branch.
4. Mounts the workspace and runs the container with `--init` to gracefully
handle termination (e.g., `Ctrl+C`).
### Running the Script
Ensure your API key is exported:
```bash
export GEMINI_API_KEY="AIzaSy..."
```
Run the script from the root of the repository:
```bash
# Uses the default prompt ("make a snake game in python")
./scripts/run_simulator_docker.sh
# Or, provide a custom prompt:
./scripts/run_simulator_docker.sh "create a simple react counter component"
```
## Manual Execution Breakdown
If you need to run the simulation manually, here is exactly what the automated
script does under the hood:
### 1. Prepare Workspace & Knowledge Source
```bash
WORKSPACE_DIR="/tmp/gemini_docker_workspace"
mkdir -p "$WORKSPACE_DIR"
touch "$WORKSPACE_DIR/knowledge.md"
chmod -R 777 "$WORKSPACE_DIR"
```
### 2. Bypass Interactive Startup Dialogs
To prevent the simulator from getting stuck on the initial Auth or Folder Trust
screens, generate a global `settings.json` file.
```bash
mkdir -p "$WORKSPACE_DIR/.gemini"
echo '{
"security": {
"auth": { "selectedType": "gemini-api-key" },
"folderTrust": { "enabled": false }
}
}' > "$WORKSPACE_DIR/.gemini/settings.json"
chmod 777 "$WORKSPACE_DIR/.gemini/settings.json"
```
### 3. Build the Image
```bash
GEMINI_SANDBOX=docker npm run build:sandbox -- -i gemini-cli-simulator:latest
```
### 4. Run the Container
Notice the `--init` flag (for `Ctrl+C` support) and the explicit mount mapping
the `settings.json` file into `/home/node/.gemini/` inside the container.
```bash
docker run -it --rm --init \
-v "$WORKSPACE_DIR:/workspace" \
-v "$WORKSPACE_DIR/.gemini/settings.json:/home/node/.gemini/settings.json" \
-w /workspace \
-e GEMINI_API_KEY="$GEMINI_API_KEY" \
-e GEMINI_DEBUG_LOG_FILE="/workspace/debug.log" \
gemini-cli-simulator:latest \
gemini --prompt-interactive "make a snake game in python" \
--approval-mode plan \
--simulate-user \
--knowledge-source "/workspace/knowledge.md"
```
## Verification
Once the simulation completes, verify the results in your workspace folder:
1. **Generated Code:** Check for project files (e.g., `snake.py`).
2. **Persistent Knowledge:** Check `knowledge.md`. You should see new rules
dynamically appended by the simulator.
3. **Logs:**
- `debug.log`: Detailed internal LLM decision logic.
- `interactions_<timestamp>.txt`: Raw screen scrape frames seen by the
simulator's "eyes".
+21
View File
@@ -18,6 +18,27 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.38.0 - 2026-04-14
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
intent and tool usage for better session structure
([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by
@Abhijit-2592,
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by
@gundermanc).
- **Context Compression Service:** Advanced context management to efficiently
distill conversation history
([#24483](https://github.com/google-gemini/gemini-cli/pull/24483) by
@joshualitt).
- **UI Flicker & UX Enhancements:** Solved rendering flicker with "Terminal
Buffer" mode and introduced selective topic expansion
([#24512](https://github.com/google-gemini/gemini-cli/pull/24512) by
@jacob314, [#24793](https://github.com/google-gemini/gemini-cli/pull/24793) by
@Abhijit-2592).
- **Persistent Policy Approvals:** Implemented context-aware persistent
approvals for tool execution
([#23257](https://github.com/google-gemini/gemini-cli/pull/23257) by @jerop).
## Announcements: v0.37.0 - 2026-04-08
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
+253 -410
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.37.2
# Latest stable release: v0.38.1
Released: April 13, 2026
Released: April 15, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,418 +11,261 @@ npm install -g @google/gemini-cli
## Highlights
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
worktree support for both Linux and Windows, enhancing development flexibility
in restricted environments.
- **Tool-Based Topic Grouping (Chapters):** Introduced "Chapters" to logically
group agent interactions based on tool usage and intent, providing a clearer
narrative flow in long sessions.
- **Enhanced Browser Agent:** Added persistent session management, dynamic
read-only tool discovery, and sandbox-aware initialization for the browser
agent.
- **Security & Permission Hardening:** Implemented secret visibility lockdown
for environment files and integrated integrity controls for Windows
sandboxing.
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
to provide better session structure and narrative continuity in long-running
tasks.
- **Context Compression Service:** Implemented a dedicated service for advanced
context management, efficiently distilling conversation history to preserve
focus and tokens.
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
solve rendering flicker, along with selective topic expansion and improved
tool confirmation layouts.
- **Context-Aware Policy Approvals:** Users can now grant persistent,
context-aware approvals for tools, significantly reducing manual confirmation
overhead for trusted workflows.
- **Background Process Monitoring:** New tools for monitoring and inspecting
background shell processes, providing better visibility into asynchronous
tasks.
## What's Changed
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
- fix(acp): handle all InvalidStreamError types gracefully in prompt
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- feat(acp): add support for /about command
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(acp): add /help command
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- feat(evals): centralize test agents into test-utils for reuse by @Samee24 in
[#23616](https://github.com/google-gemini/gemini-cli/pull/23616)
- revert: chore(config): disable agents by default by @abhipatel12 in
[#23672](https://github.com/google-gemini/gemini-cli/pull/23672)
- fix(plan): update telemetry attribute keys and add timestamp by @Adib234 in
[#23685](https://github.com/google-gemini/gemini-cli/pull/23685)
- fix(core): prevent premature MCP discovery completion by @jackwotherspoon in
[#23637](https://github.com/google-gemini/gemini-cli/pull/23637)
- feat(browser): add maxActionsPerTask for browser agent setting by
@cynthialong0-0 in
[#23216](https://github.com/google-gemini/gemini-cli/pull/23216)
- fix(core): improve agent loader error formatting for empty paths by
@adamfweidman in
[#23690](https://github.com/google-gemini/gemini-cli/pull/23690)
- fix(cli): only show updating spinner when auto-update is in progress by
@scidomino in [#23709](https://github.com/google-gemini/gemini-cli/pull/23709)
- Refine onboarding metrics to log the duration explicitly and use the tier
name. by @yunaseoul in
[#23678](https://github.com/google-gemini/gemini-cli/pull/23678)
- chore(tools): add toJSON to tools and invocations to reduce logging verbosity
by @alisa-alisa in
[#22899](https://github.com/google-gemini/gemini-cli/pull/22899)
- fix(cli): stabilize copy mode to prevent flickering and cursor resets by
@mattKorwel in
[#22584](https://github.com/google-gemini/gemini-cli/pull/22584)
- fix(test): move flaky ctrl-c-exit test to non-blocking suite by @mattKorwel in
[#23732](https://github.com/google-gemini/gemini-cli/pull/23732)
- feat(skills): add ci skill for automated failure replication by @mattKorwel in
[#23720](https://github.com/google-gemini/gemini-cli/pull/23720)
- feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers by
@ehedlund in [#23282](https://github.com/google-gemini/gemini-cli/pull/23282)
- fix(core): conditionally expose additional_permissions in shell tool by
@galz10 in [#23729](https://github.com/google-gemini/gemini-cli/pull/23729)
- refactor(core): standardize OS-specific sandbox tests and extract linux helper
methods by @ehedlund in
[#23715](https://github.com/google-gemini/gemini-cli/pull/23715)
- format recently added script by @scidomino in
[#23739](https://github.com/google-gemini/gemini-cli/pull/23739)
- fix(ui): prevent over-eager slash subcommand completion by @keithguerin in
[#20136](https://github.com/google-gemini/gemini-cli/pull/20136)
- Fix dynamic model routing for gemini 3.1 pro to customtools model by
@kevinjwang1 in
[#23641](https://github.com/google-gemini/gemini-cli/pull/23641)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(cli): skip console log/info in headless mode by @cynthialong0-0 in
[#22739](https://github.com/google-gemini/gemini-cli/pull/22739)
- test(core): install bubblewrap on Linux CI for sandbox integration tests by
@ehedlund in [#23583](https://github.com/google-gemini/gemini-cli/pull/23583)
- docs(reference): split tools table into category sections by @sheikhlimon in
[#21516](https://github.com/google-gemini/gemini-cli/pull/21516)
- fix(browser): detect embedded URLs in query params to prevent allowedDomains
bypass by @tony-shi in
[#23225](https://github.com/google-gemini/gemini-cli/pull/23225)
- fix(browser): add proxy bypass constraint to domain restriction system prompt
by @tony-shi in
[#23229](https://github.com/google-gemini/gemini-cli/pull/23229)
- fix(policy): relax write_file argsPattern in plan mode to allow paths without
session ID by @Adib234 in
[#23695](https://github.com/google-gemini/gemini-cli/pull/23695)
- docs: fix grammar in CONTRIBUTING and numbering in sandbox docs by
@splint-disk-8i in
[#23448](https://github.com/google-gemini/gemini-cli/pull/23448)
- fix(acp): allow attachments by adding a permission prompt by @sripasg in
[#23680](https://github.com/google-gemini/gemini-cli/pull/23680)
- fix(core): thread AbortSignal to chat compression requests (#20405) by
@SH20RAJ in [#20778](https://github.com/google-gemini/gemini-cli/pull/20778)
- feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 by
@scidomino in [#23691](https://github.com/google-gemini/gemini-cli/pull/23691)
- Add note about root privileges in sandbox docs by @diodesign in
[#23314](https://github.com/google-gemini/gemini-cli/pull/23314)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- fix(cli): resolve TTY hang on headless environments by unconditionally
resuming process.stdin before React Ink launch by @cocosheng-g in
[#23673](https://github.com/google-gemini/gemini-cli/pull/23673)
- fix(ui): cleanup estimated string length hacks in composer by @keithguerin in
[#23694](https://github.com/google-gemini/gemini-cli/pull/23694)
- feat(browser): dynamically discover read-only tools by @cynthialong0-0 in
[#23805](https://github.com/google-gemini/gemini-cli/pull/23805)
- docs: clarify policy requirement for `general.plan.directory` in settings
schema by @jerop in
[#23784](https://github.com/google-gemini/gemini-cli/pull/23784)
- Revert "perf(cli): optimize --version startup time (#23671)" by @scidomino in
[#23812](https://github.com/google-gemini/gemini-cli/pull/23812)
- don't silence errors from wombat by @scidomino in
[#23822](https://github.com/google-gemini/gemini-cli/pull/23822)
- fix(ui): prevent escape key from cancelling requests in shell mode by
@PrasannaPal21 in
[#21245](https://github.com/google-gemini/gemini-cli/pull/21245)
- Changelog for v0.36.0-preview.0 by @gemini-cli-robot in
[#23702](https://github.com/google-gemini/gemini-cli/pull/23702)
- feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite by
@chrstnb in [#23794](https://github.com/google-gemini/gemini-cli/pull/23794)
- Changelog for v0.36.0-preview.3 by @gemini-cli-robot in
[#23827](https://github.com/google-gemini/gemini-cli/pull/23827)
- new linting check: github-actions-pinning by @alisa-alisa in
[#23808](https://github.com/google-gemini/gemini-cli/pull/23808)
- fix(cli): show helpful guidance when no skills are available by @Niralisj in
[#23785](https://github.com/google-gemini/gemini-cli/pull/23785)
- fix: Chat logs and errors handle tail tool calls correctly by @googlestrobe in
[#22460](https://github.com/google-gemini/gemini-cli/pull/22460)
- Don't try removing a tag from a non-existent release. by @scidomino in
[#23830](https://github.com/google-gemini/gemini-cli/pull/23830)
- fix(cli): allow ask question dialog to take full window height by @jacob314 in
[#23693](https://github.com/google-gemini/gemini-cli/pull/23693)
- fix(core): strip leading underscores from error types in telemetry by
@yunaseoul in [#23824](https://github.com/google-gemini/gemini-cli/pull/23824)
- Changelog for v0.35.0 by @gemini-cli-robot in
[#23819](https://github.com/google-gemini/gemini-cli/pull/23819)
- feat(evals): add reliability harvester and 500/503 retry support by
@alisa-alisa in
[#23626](https://github.com/google-gemini/gemini-cli/pull/23626)
- feat(sandbox): dynamic Linux sandbox expansion and worktree support by @galz10
in [#23692](https://github.com/google-gemini/gemini-cli/pull/23692)
- Merge examples of use into quickstart documentation by @diodesign in
[#23319](https://github.com/google-gemini/gemini-cli/pull/23319)
- fix(cli): prioritize primary name matches in slash command search by @sehoon38
in [#23850](https://github.com/google-gemini/gemini-cli/pull/23850)
- Changelog for v0.35.1 by @gemini-cli-robot in
[#23840](https://github.com/google-gemini/gemini-cli/pull/23840)
- fix(browser): keep input blocker active across navigations by @kunal-10-cloud
in [#22562](https://github.com/google-gemini/gemini-cli/pull/22562)
- feat(core): new skill to look for duplicated code while reviewing PRs by
@devr0306 in [#23704](https://github.com/google-gemini/gemini-cli/pull/23704)
- fix(core): replace hardcoded non-interactive ASK_USER denial with explicit
policy rules by @ruomengz in
[#23668](https://github.com/google-gemini/gemini-cli/pull/23668)
- fix(plan): after exiting plan mode switches model to a flash model by @Adib234
in [#23885](https://github.com/google-gemini/gemini-cli/pull/23885)
- feat(gcp): add development worker infrastructure by @mattKorwel in
[#23814](https://github.com/google-gemini/gemini-cli/pull/23814)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(core): define TrajectoryProvider interface by @sehoon38 in
[#23050](https://github.com/google-gemini/gemini-cli/pull/23050)
- Docs: Update quotas and pricing by @jkcinouye in
[#23835](https://github.com/google-gemini/gemini-cli/pull/23835)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- feat(cli): enable notifications cross-platform via terminal bell fallback by
@genneth in [#21618](https://github.com/google-gemini/gemini-cli/pull/21618)
- feat(sandbox): implement secret visibility lockdown for env files by
@DavidAPierce in
[#23712](https://github.com/google-gemini/gemini-cli/pull/23712)
- fix(core): remove shell outputChunks buffer caching to prevent memory bloat
and sanitize prompt input by @spencer426 in
[#23751](https://github.com/google-gemini/gemini-cli/pull/23751)
- feat(core): implement persistent browser session management by @kunal-10-cloud
in [#21306](https://github.com/google-gemini/gemini-cli/pull/21306)
- refactor(core): delegate sandbox denial parsing to SandboxManager by
@scidomino in [#23928](https://github.com/google-gemini/gemini-cli/pull/23928)
- dep(update) Update Ink version to 6.5.0 by @jacob314 in
[#23843](https://github.com/google-gemini/gemini-cli/pull/23843)
- Docs: Update 'docs-writer' skill for relative links by @jkcinouye in
[#21463](https://github.com/google-gemini/gemini-cli/pull/21463)
- Changelog for v0.36.0-preview.4 by @gemini-cli-robot in
[#23935](https://github.com/google-gemini/gemini-cli/pull/23935)
- fix(acp): Update allow approval policy flow for ACP clients to fix config
persistence and compatible with TUI by @sripasg in
[#23818](https://github.com/google-gemini/gemini-cli/pull/23818)
- Changelog for v0.35.2 by @gemini-cli-robot in
[#23960](https://github.com/google-gemini/gemini-cli/pull/23960)
- ACP integration documents by @g-samroberts in
[#22254](https://github.com/google-gemini/gemini-cli/pull/22254)
- fix(core): explicitly set error names to avoid bundling renaming issues by
@yunaseoul in [#23913](https://github.com/google-gemini/gemini-cli/pull/23913)
- feat(core): subagent isolation and cleanup hardening by @abhipatel12 in
[#23903](https://github.com/google-gemini/gemini-cli/pull/23903)
- disable extension-reload test by @scidomino in
[#24018](https://github.com/google-gemini/gemini-cli/pull/24018)
- feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor
createSandboxManager by @ehedlund in
[#23936](https://github.com/google-gemini/gemini-cli/pull/23936)
- refactor(core): improve ignore resolution and fix directory-matching bug by
@ehedlund in [#23816](https://github.com/google-gemini/gemini-cli/pull/23816)
- revert(core): support custom base URL via env vars by @spencer426 in
[#23976](https://github.com/google-gemini/gemini-cli/pull/23976)
- Increase memory limited for eslint. by @jacob314 in
[#24022](https://github.com/google-gemini/gemini-cli/pull/24022)
- fix(acp): prevent crash on empty response in ACP mode by @sripasg in
[#23952](https://github.com/google-gemini/gemini-cli/pull/23952)
- feat(core): Land `AgentHistoryProvider`. by @joshualitt in
[#23978](https://github.com/google-gemini/gemini-cli/pull/23978)
- fix(core): switch to subshells for shell tool wrapping to fix heredocs and
edge cases by @abhipatel12 in
[#24024](https://github.com/google-gemini/gemini-cli/pull/24024)
- Debug command. by @jacob314 in
[#23851](https://github.com/google-gemini/gemini-cli/pull/23851)
- Changelog for v0.36.0-preview.5 by @gemini-cli-robot in
[#24046](https://github.com/google-gemini/gemini-cli/pull/24046)
- Fix test flakes by globally mocking ink-spinner by @jacob314 in
[#24044](https://github.com/google-gemini/gemini-cli/pull/24044)
- Enable network access in sandbox configuration by @galz10 in
[#24055](https://github.com/google-gemini/gemini-cli/pull/24055)
- feat(context): add configurable memoryBoundaryMarkers setting by @SandyTao520
in [#24020](https://github.com/google-gemini/gemini-cli/pull/24020)
- feat(core): implement windows sandbox expansion and denial detection by
@scidomino in [#24027](https://github.com/google-gemini/gemini-cli/pull/24027)
- fix(core): resolve ACP Operation Aborted Errors in grep_search by @ivanporty
in [#23821](https://github.com/google-gemini/gemini-cli/pull/23821)
- fix(hooks): prevent SessionEnd from firing twice in non-interactive mode by
@krishdef7 in [#22139](https://github.com/google-gemini/gemini-cli/pull/22139)
- Re-word intro to Gemini 3 page. by @g-samroberts in
[#24069](https://github.com/google-gemini/gemini-cli/pull/24069)
- fix(cli): resolve layout contention and flashing loop in StatusRow by
@keithguerin in
[#24065](https://github.com/google-gemini/gemini-cli/pull/24065)
- fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox
by @galz10 in [#24057](https://github.com/google-gemini/gemini-cli/pull/24057)
- feat(core): implement tool-based topic grouping (Chapters) by @Abhijit-2592 in
[#23150](https://github.com/google-gemini/gemini-cli/pull/23150)
- feat(cli): support 'tab to queue' for messages while generating by @gundermanc
in [#24052](https://github.com/google-gemini/gemini-cli/pull/24052)
- feat(core): agnostic background task UI with CompletionBehavior by
@adamfweidman in
[#22740](https://github.com/google-gemini/gemini-cli/pull/22740)
- UX for topic narration tool by @gundermanc in
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079)
- fix: shellcheck warnings in scripts by @scidomino in
[#24035](https://github.com/google-gemini/gemini-cli/pull/24035)
- test(evals): add comprehensive subagent delegation evaluations by @abhipatel12
in [#24132](https://github.com/google-gemini/gemini-cli/pull/24132)
- fix(a2a-server): prioritize ADC before evaluating headless constraints for
auth initialization by @spencer426 in
[#23614](https://github.com/google-gemini/gemini-cli/pull/23614)
- Text can be added after /plan command by @rambleraptor in
[#22833](https://github.com/google-gemini/gemini-cli/pull/22833)
- fix(cli): resolve missing F12 logs via global console store by @scidomino in
[#24235](https://github.com/google-gemini/gemini-cli/pull/24235)
- fix broken tests by @scidomino in
[#24279](https://github.com/google-gemini/gemini-cli/pull/24279)
- fix(evals): add update_topic behavioral eval by @gundermanc in
[#24223](https://github.com/google-gemini/gemini-cli/pull/24223)
- feat(core): Unified Context Management and Tool Distillation. by @joshualitt
in [#24157](https://github.com/google-gemini/gemini-cli/pull/24157)
- Default enable narration for the team. by @gundermanc in
[#24224](https://github.com/google-gemini/gemini-cli/pull/24224)
- fix(core): ensure default agents provide tools and use model-specific schemas
by @abhipatel12 in
[#24268](https://github.com/google-gemini/gemini-cli/pull/24268)
- feat(cli): show Flash Lite Preview model regardless of user tier by @sehoon38
in [#23904](https://github.com/google-gemini/gemini-cli/pull/23904)
- feat(cli): implement compact tool output by @jwhelangoog in
[#20974](https://github.com/google-gemini/gemini-cli/pull/20974)
- Add security settings for tool sandboxing by @galz10 in
[#23923](https://github.com/google-gemini/gemini-cli/pull/23923)
- chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL by
@sehoon38 in [#24276](https://github.com/google-gemini/gemini-cli/pull/24276)
- feat(core): enable topic update narration for legacy models by @Abhijit-2592
in [#24241](https://github.com/google-gemini/gemini-cli/pull/24241)
- feat(core): add project-level memory scope to save_memory tool by @SandyTao520
in [#24161](https://github.com/google-gemini/gemini-cli/pull/24161)
- test(integration): fix plan mode write denial test false positive by @sehoon38
in [#24299](https://github.com/google-gemini/gemini-cli/pull/24299)
- feat(plan): support `Plan` mode in untrusted folders by @Adib234 in
[#17586](https://github.com/google-gemini/gemini-cli/pull/17586)
- fix(core): enable mid-stream retries for all models and re-enable compression
test by @sehoon38 in
[#24302](https://github.com/google-gemini/gemini-cli/pull/24302)
- Changelog for v0.36.0-preview.6 by @gemini-cli-robot in
[#24082](https://github.com/google-gemini/gemini-cli/pull/24082)
- Changelog for v0.35.3 by @gemini-cli-robot in
[#24083](https://github.com/google-gemini/gemini-cli/pull/24083)
- feat(cli): add auth info to footer by @sehoon38 in
[#24042](https://github.com/google-gemini/gemini-cli/pull/24042)
- fix(browser): reset action counter for each agent session and let it ignore
internal actions by @cynthialong0-0 in
[#24228](https://github.com/google-gemini/gemini-cli/pull/24228)
- feat(plan): promote planning feature to stable by @ruomengz in
[#24282](https://github.com/google-gemini/gemini-cli/pull/24282)
- fix(browser): terminate subagent immediately on domain restriction violations
by @gsquared94 in
[#24313](https://github.com/google-gemini/gemini-cli/pull/24313)
- feat(cli): add UI to update extensions by @ruomengz in
[#23682](https://github.com/google-gemini/gemini-cli/pull/23682)
- Fix(browser): terminate immediately for "browser is already running" error by
@cynthialong0-0 in
[#24233](https://github.com/google-gemini/gemini-cli/pull/24233)
- docs: Add 'plan' option to approval mode in CLI reference by @YifanRuan in
[#24134](https://github.com/google-gemini/gemini-cli/pull/24134)
- fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX
errors by @ehedlund in
[#24255](https://github.com/google-gemini/gemini-cli/pull/24255)
- fix(core): fix race condition between browser agent and main closing process
by @cynthialong0-0 in
[#24340](https://github.com/google-gemini/gemini-cli/pull/24340)
- perf(build): optimize build scripts for parallel execution and remove
redundant checks by @sehoon38 in
[#24307](https://github.com/google-gemini/gemini-cli/pull/24307)
- ci: install bubblewrap on Linux for release workflows by @ehedlund in
[#24347](https://github.com/google-gemini/gemini-cli/pull/24347)
- chore(release): allow bundling for all builds, including stable by @sehoon38
in [#24305](https://github.com/google-gemini/gemini-cli/pull/24305)
- Revert "Add security settings for tool sandboxing" by @jerop in
[#24357](https://github.com/google-gemini/gemini-cli/pull/24357)
- docs: update subagents docs to not be experimental by @abhipatel12 in
[#24343](https://github.com/google-gemini/gemini-cli/pull/24343)
- fix(core): implement **read and **write commands in sandbox managers by
@galz10 in [#24283](https://github.com/google-gemini/gemini-cli/pull/24283)
- don't try to remove tags in dry run by @scidomino in
[#24356](https://github.com/google-gemini/gemini-cli/pull/24356)
- fix(config): disable JIT context loading by default by @SandyTao520 in
[#24364](https://github.com/google-gemini/gemini-cli/pull/24364)
- test(sandbox): add integration test for dynamic permission expansion by
@galz10 in [#24359](https://github.com/google-gemini/gemini-cli/pull/24359)
- docs(policy): remove unsupported mcpName wildcard edge case by @abhipatel12 in
[#24133](https://github.com/google-gemini/gemini-cli/pull/24133)
- docs: fix broken GEMINI.md link in CONTRIBUTING.md by @Panchal-Tirth in
[#24182](https://github.com/google-gemini/gemini-cli/pull/24182)
- feat(core): infrastructure for event-driven subagent history by @abhipatel12
in [#23914](https://github.com/google-gemini/gemini-cli/pull/23914)
- fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox
restrictions by @DavidAPierce in
[#24047](https://github.com/google-gemini/gemini-cli/pull/24047)
- fix(core): fix browser agent UX issues and improve E2E test reliability by
@gsquared94 in
[#24312](https://github.com/google-gemini/gemini-cli/pull/24312)
- fix(ui): wrap topic and intent fields in TopicMessage by @jwhelangoog in
[#24386](https://github.com/google-gemini/gemini-cli/pull/24386)
- refactor(core): Centralize context management logic into src/context by
@joshualitt in
[#24380](https://github.com/google-gemini/gemini-cli/pull/24380)
- fix(core): pin AuthType.GATEWAY to use Gemini 3.1 Pro/Flash Lite by default by
@sripasg in [#24375](https://github.com/google-gemini/gemini-cli/pull/24375)
- feat(ui): add Tokyo Night theme by @danrneal in
[#24054](https://github.com/google-gemini/gemini-cli/pull/24054)
- fix(cli): refactor test config loading and mock debugLogger in test-setup by
@mattKorwel in
[#24389](https://github.com/google-gemini/gemini-cli/pull/24389)
- Set memoryManager to false in settings.json by @mattKorwel in
[#24393](https://github.com/google-gemini/gemini-cli/pull/24393)
- ink 6.6.3 by @jacob314 in
[#24372](https://github.com/google-gemini/gemini-cli/pull/24372)
- fix(core): resolve subagent chat recording gaps and directory inheritance by
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24368](https://github.com/google-gemini/gemini-cli/pull/24368)
- fix(cli): cap shell output at 10 MB to prevent RangeError crash by @ProthamD
in [#24168](https://github.com/google-gemini/gemini-cli/pull/24168)
- feat(plan): conditionally add enter/exit plan mode tools based on current mode
by @ruomengz in
[#24378](https://github.com/google-gemini/gemini-cli/pull/24378)
- feat(core): prioritize discussion before formal plan approval by @jerop in
[#24423](https://github.com/google-gemini/gemini-cli/pull/24423)
- fix(ui): add accelerated scrolling on alternate buffer mode by @devr0306 in
[#23940](https://github.com/google-gemini/gemini-cli/pull/23940)
- feat(core): populate sandbox forbidden paths with project ignore file contents
by @ehedlund in
[#24038](https://github.com/google-gemini/gemini-cli/pull/24038)
- fix(core): ensure blue border overlay and input blocker to act correctly
depending on browser agent activities by @cynthialong0-0 in
[#24385](https://github.com/google-gemini/gemini-cli/pull/24385)
- fix(ui): removed additional vertical padding for tables by @devr0306 in
[#24381](https://github.com/google-gemini/gemini-cli/pull/24381)
- fix(build): upload full bundle directory archive to GitHub releases by
@sehoon38 in [#24403](https://github.com/google-gemini/gemini-cli/pull/24403)
- fix(build): wire bundle:browser-mcp into bundle pipeline by @gsquared94 in
[#24424](https://github.com/google-gemini/gemini-cli/pull/24424)
- feat(browser): add sandbox-aware browser agent initialization by @gsquared94
in [#24419](https://github.com/google-gemini/gemini-cli/pull/24419)
- feat(core): enhance tracker task schemas for detailed titles and descriptions
by @anj-s in [#23902](https://github.com/google-gemini/gemini-cli/pull/23902)
- refactor(core): Unified context management settings schema by @joshualitt in
[#24391](https://github.com/google-gemini/gemini-cli/pull/24391)
- feat(core): update browser agent prompt to check open pages first when
bringing up by @cynthialong0-0 in
[#24431](https://github.com/google-gemini/gemini-cli/pull/24431)
- fix(acp) refactor(core,cli): centralize model discovery logic in
ModelConfigService by @sripasg in
[#24392](https://github.com/google-gemini/gemini-cli/pull/24392)
- Changelog for v0.36.0-preview.7 by @gemini-cli-robot in
[#24346](https://github.com/google-gemini/gemini-cli/pull/24346)
- fix: update task tracker storage location in system prompt by @anj-s in
[#24034](https://github.com/google-gemini/gemini-cli/pull/24034)
- feat(browser): supersede stale snapshots to reclaim context-window tokens by
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24440](https://github.com/google-gemini/gemini-cli/pull/24440)
- docs(core): add subagent tool isolation draft doc by @akh64bit in
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
@gemini-cli-robot in
[#24561](https://github.com/google-gemini/gemini-cli/pull/24561)
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
@gemini-cli-robot in
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
+3 -1
View File
@@ -130,7 +130,9 @@ These are the only allowed tools:
[`cli_help`](../core/subagents.md#cli-help-agent)
- **Interaction:** [`ask_user`](../tools/ask-user.md)
- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
example, `github_read_issue`, `postgres_read_schema`) are allowed.
example, `github_read_issue`, `postgres_read_schema`) and core
[MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`,
`read_mcp_resource`) are allowed.
- **Planning (Write):**
[`write_file`](../tools/file-system.md#3-write_file-writefile) and
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
+15 -14
View File
@@ -24,20 +24,21 @@ they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
| 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 | `true` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
| UI Label | Setting | Description | Default |
| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Terminal Notifications | `general.enableNotifications` | Enable terminal run-event notifications for action-required prompts and session completion. | `false` |
| Terminal Notification Method | `general.notificationMethod` | How to send terminal notifications. | `"auto"` |
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
| 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 | `true` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
### Output
+22 -2
View File
@@ -134,10 +134,15 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **`general.enableNotifications`** (boolean):
- **Description:** Enable run-event notifications for action-required prompts
and session completion.
- **Description:** Enable terminal run-event notifications for action-required
prompts and session completion.
- **Default:** `false`
- **`general.notificationMethod`** (enum):
- **Description:** How to send terminal notifications.
- **Default:** `"auto"`
- **Values:** `"auto"`, `"osc9"`, `"osc777"`, `"bell"`
- **`general.checkpointing.enabled`** (boolean):
- **Description:** Enable session checkpointing for recovery
- **Default:** `false`
@@ -2148,6 +2153,21 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- When set, overrides the default API version used by the SDK.
- Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell:
`$env:GOOGLE_GENAI_API_VERSION="v1"`)
- **`GOOGLE_GEMINI_BASE_URL`**:
- Overrides the default base URL for Gemini API requests (when using
`gemini-api-key` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows
PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`)
- **`GOOGLE_VERTEX_BASE_URL`**:
- Overrides the default base URL for Vertex AI API requests (when using
`vertex-ai` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`
(Windows PowerShell:
`$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`)
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
- Your Google Cloud Project ID for Telemetry in Google Cloud
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows
+18 -12
View File
@@ -120,6 +120,12 @@ There are three possible decisions a rule can enforce:
### Priority system and tiers
> [!WARNING] The **Workspace** tier (project-level policies) is currently
> non-functional. Defining policies in a workspace's `.gemini/policies`
> directory will not have any effect. See
> [issue #18186](https://github.com/google-gemini/gemini-cli/issues/18186). Use
> User or Admin policies instead.
The policy engine uses a sophisticated priority system to resolve conflicts when
multiple rules match a single tool call. The core principle is simple: **the
rule with the highest priority wins**.
@@ -127,13 +133,13 @@ rule with the highest priority wins**.
To provide a clear hierarchy, policies are organized into three tiers. Each tier
has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :-------- | :--- | :-------------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with Gemini CLI. |
| Extension | 2 | Policies defined in extensions. |
| Workspace | 3 | Policies defined in the current workspace's configuration directory. |
| User | 4 | Custom policies defined by the user. |
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
| Tier | Base | Description |
| :-------- | :--- | :-------------------------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with Gemini CLI. |
| Extension | 2 | Policies defined in extensions. |
| Workspace | 3 | **(Currently disabled)** Policies defined in the current workspace's configuration directory. |
| User | 4 | Custom policies defined by the user. |
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
@@ -214,11 +220,11 @@ User, and (if configured) Admin directories.
### Policy locations
| Tier | Type | Location |
| :------------ | :----- | :---------------------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
| Tier | Type | Location |
| :------------ | :----- | :------------------------------------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
#### System-wide policies (Admin)
+7
View File
@@ -92,6 +92,13 @@ each tool.
| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. |
| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. |
### MCP
| Tool | Kind | Description |
| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- |
| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. |
| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. |
### Memory
| Tool | Kind | Description |
+8 -1
View File
@@ -122,7 +122,14 @@
}
]
},
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "MCP servers",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/tools/mcp-server" },
{ "label": "Resource tools", "slug": "docs/tools/mcp-resources" }
]
},
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{
+44
View File
@@ -0,0 +1,44 @@
# MCP resource tools
MCP resource tools let Gemini CLI discover and retrieve data from contextual
resources exposed by Model Context Protocol (MCP) servers.
## 1. `list_mcp_resources` (ListMcpResources)
`list_mcp_resources` retrieves a list of all available resources from connected
MCP servers. This is primarily a discovery tool that helps the model understand
what external data sources are available for reference.
- **Tool name:** `list_mcp_resources`
- **Display name:** List MCP Resources
- **Kind:** `Search`
- **File:** `list-mcp-resources.ts`
- **Parameters:**
- `serverName` (string, optional): An optional filter to list resources from a
specific server.
- **Behavior:**
- Iterates through all connected MCP servers.
- Fetches the list of resources each server exposes.
- Formats the results into a plain-text list of URIs and descriptions.
- **Output (`llmContent`):** A formatted list of available resources, including
their URI, server name, and optional description.
- **Confirmation:** No. This is a read-only discovery tool.
## 2. `read_mcp_resource` (ReadMcpResource)
`read_mcp_resource` retrieves the content of a specific resource identified by
its URI.
- **Tool name:** `read_mcp_resource`
- **Display name:** Read MCP Resource
- **Kind:** `Read`
- **File:** `read-mcp-resource.ts`
- **Parameters:**
- `uri` (string, required): The URI of the MCP resource to read.
- **Behavior:**
- Locates the resource and its associated server by URI.
- Calls the server's `resources/read` method.
- Processes the response, extracting text or binary data.
- **Output (`llmContent`):** The content of the resource. For binary data, it
returns a placeholder indicating the data type.
- **Confirmation:** No. This is a read-only retrieval tool.
+2 -1
View File
@@ -64,7 +64,8 @@ Gemini CLI supports three MCP transport types:
Some MCP servers expose contextual “resources” in addition to the tools and
prompts. Gemini CLI discovers these automatically and gives you the possibility
to reference them in the chat.
to reference them in the chat. For more information on the tools used to
interact with these resources, see [MCP resource tools](mcp-resources.md).
### Discovery and listing
+4
View File
@@ -11,6 +11,8 @@ import path from 'node:path';
describe('Background Process Monitoring', () => {
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should naturally use read output tool to find token',
prompt:
"Run the script using 'bash generate_token.sh'. It will emit a token after a short delay and continue running. Find the token and tell me what it is.",
@@ -50,6 +52,8 @@ sleep 100
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should naturally use list tool to verify multiple processes',
prompt:
"Start three background processes that run 'sleep 100', 'sleep 200', and 'sleep 300' respectively. Verify that all three are currently running.",
+11 -3
View File
@@ -17,9 +17,17 @@ describe('CliHelpAgent Delegation', () => {
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
const toolCallIndex = toolLogs.findIndex((log) => {
if (log.toolRequest.name === 'invoke_agent') {
try {
const args = JSON.parse(log.toolRequest.args);
return args.agent_name === 'cli_help';
} catch {
return false;
}
}
return false;
});
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
+17 -1
View File
@@ -16,6 +16,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { vi } from 'vitest';
import {
Config,
type ConfigParameters,
@@ -52,6 +53,7 @@ export interface ComponentEvalCase extends BaseEvalCase {
export class ComponentRig {
public config: Config | undefined;
public testDir: string;
public homeDir: string;
public sessionId: string;
constructor(
@@ -61,6 +63,9 @@ export class ComponentRig {
this.testDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-rig-${uniqueId.slice(0, 8)}-`),
);
this.homeDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-home-${uniqueId.slice(0, 8)}-`),
);
this.sessionId = `test-session-${uniqueId}`;
}
@@ -89,12 +94,23 @@ export class ComponentRig {
this.config = makeFakeConfig(configParams);
await this.config.initialize();
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient.
// This must happen BEFORE stubbing GEMINI_CLI_HOME because OAuth credential
// lookup resolves through homedir() → GEMINI_CLI_HOME.
await this.config.refreshAuth(AuthType.USE_GEMINI);
// Isolate storage paths (session files, skills, extraction state) by
// pointing GEMINI_CLI_HOME at a per-test temp directory. Storage resolves
// global paths through `homedir()` which reads this env var. This is set
// after auth so credential lookup uses the real home directory.
vi.stubEnv('GEMINI_CLI_HOME', this.homeDir);
}
async cleanup() {
await this.config?.dispose();
vi.unstubAllEnvs();
fs.rmSync(this.testDir, { recursive: true, force: true });
fs.rmSync(this.homeDir, { recursive: true, force: true });
}
}
+14 -3
View File
@@ -26,11 +26,22 @@ describe('generalist_agent', () => {
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked
const foundToolCall = await rig.waitForToolCall('generalist');
// 1) Verify the generalist agent was invoked via invoke_agent
const foundToolCall = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => {
try {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
} catch {
return false;
}
},
);
expect(
foundToolCall,
'Expected to find a tool call for generalist agent',
'Expected to find an invoke_agent tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
+5 -1
View File
@@ -298,6 +298,8 @@ describe('plan_mode', () => {
});
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
params: {
settings,
@@ -333,7 +335,7 @@ describe('plan_mode', () => {
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
`Expected write_file to succeed, but got error: ${(planWrite?.toolRequest as any).error}`,
).toBe(true);
assertModelHasOutput(result);
@@ -341,6 +343,8 @@ describe('plan_mode', () => {
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should not exit plan mode or draft before informal agreement',
approvalMode: ApprovalMode.PLAN,
params: {
+63 -31
View File
@@ -145,22 +145,30 @@ describe('save_memory', () => {
},
});
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
const savingDbSchemaLocationAsProjectMemory =
'Agent saves workspace database schema location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringDbSchemaLocation,
name: savingDbSchemaLocationAsProjectMemory,
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -188,42 +196,59 @@ describe('save_memory', () => {
},
});
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
const savingBuildArtifactLocationAsProjectMemory =
'Agent saves workspace build artifact location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringBuildArtifactLocation,
name: savingBuildArtifactLocationAsProjectMemory,
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
const savingMainEntryPointAsProjectMemory =
'Agent saves workspace main entry point as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringMainEntryPoint,
name: savingMainEntryPointAsProjectMemory,
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -317,13 +342,13 @@ describe('save_memory', () => {
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
'invoke_agent',
undefined,
(args) => /vitest/i.test(args),
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
);
expect(
wasToolCalled,
'Expected save_memory to be called with the Vitest preference from the conversation history',
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
).toBe(true);
assertModelHasOutput(result);
@@ -379,8 +404,15 @@ describe('save_memory', () => {
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory to be called').toBe(true);
const wasToolCalled = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => /save_memory/i.test(args),
);
expect(
wasToolCalled,
'Expected invoke_agent to be called with save_memory agent',
).toBe(true);
assertModelHasOutput(result);
},
+349
View File
@@ -0,0 +1,349 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import { describe, expect } from 'vitest';
import {
type Config,
ApprovalMode,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
import { componentEvalTest } from './component-test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
timestampOffsetMinutes: number;
}
interface MessageRecord {
id: string;
timestamp: string;
type: string;
content: Array<{ text: string }>;
}
const WORKSPACE_FILES = {
'package.json': JSON.stringify(
{
name: 'skill-extraction-eval',
private: true,
scripts: {
build: 'echo build',
lint: 'echo lint',
test: 'echo test',
},
},
null,
2,
),
'README.md': `# Skill Extraction Eval
This workspace exists to exercise background skill extraction from prior chats.
`,
};
function buildMessages(userTurns: string[]): MessageRecord[] {
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
return userTurns.flatMap((text, index) => [
{
id: `u${index + 1}`,
timestamp: baseTime,
type: 'user',
content: [{ text }],
},
{
id: `a${index + 1}`,
timestamp: baseTime,
type: 'gemini',
content: [{ text: `Acknowledged: ${index + 1}` }],
},
]);
}
async function seedSessions(
config: Config,
sessions: SeedSession[],
): Promise<void> {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
await fsp.mkdir(chatsDir, { recursive: true });
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
const timestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
)
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
const conversation = {
sessionId: session.sessionId,
projectHash: getProjectHash(projectRoot),
summary: session.summary,
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
messages: buildMessages(session.userTurns),
};
await fsp.writeFile(
path.join(chatsDir, filename),
JSON.stringify(conversation, null, 2),
);
}
}
async function runExtractionAndReadState(config: Config): Promise<{
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
skillsDir: string;
}> {
await startMemoryService(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const statePath = path.join(memoryDir, '.extraction-state.json');
const raw = await fsp.readFile(statePath, 'utf-8');
const state = JSON.parse(raw) as {
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
};
if (!Array.isArray(state.runs) || state.runs.length === 0) {
throw new Error('Skill extraction finished without writing any run state');
}
return {
state: {
runs: state.runs.map((run) => ({
sessionIds: Array.isArray(run.sessionIds) ? run.sessionIds : [],
skillsCreated: Array.isArray(run.skillsCreated)
? run.skillsCreated
: [],
})),
},
skillsDir,
};
}
async function readSkillBodies(skillsDir: string): Promise<string[]> {
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
const skillDirs = entries.filter((entry) => entry.isDirectory());
const bodies = await Promise.all(
skillDirs.map((entry) =>
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
),
);
return bodies;
} catch {
return [];
}
}
/**
* Shared configOverrides for all skill extraction component evals.
* - experimentalMemoryManager: enables the memory extraction pipeline.
* - approvalMode: YOLO auto-approves tool calls (write_file, read_file) so the
* background agent can execute without interactive confirmation.
*/
const EXTRACTION_CONFIG_OVERRIDES = {
experimentalMemoryManager: true,
approvalMode: ApprovalMode.YOLO,
};
describe('Skill Extraction', () => {
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'ignores one-off incidents even when session summaries look similar',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'incident-login-redirect',
summary: 'Debug login redirect loop in staging',
timestampOffsetMinutes: 420,
userTurns: [
'We only need a one-off fix for incident INC-4412 on branch hotfix/login-loop.',
'The exact failing string is ERR_REDIRECT_4412 and this workaround is incident-specific.',
'Patch packages/auth/src/redirect.ts just for this branch and do not generalize it.',
'The thing that worked was deleting the stale staging cookie before retrying.',
'This is not a normal workflow and should not become a reusable instruction.',
'It only reproduced against the 2026-04-08 staging rollout.',
'After the cookie clear, the branch-specific redirect logic passed.',
'Do not turn this incident writeup into a standing process.',
'Yes, the hotfix worked for this exact redirect-loop incident.',
'Close out INC-4412 once the staging login succeeds again.',
],
},
{
sessionId: 'incident-login-timeout',
summary: 'Debug login callback timeout in staging',
timestampOffsetMinutes: 360,
userTurns: [
'This is another one-off staging incident, this time TICKET-991 for callback timeout.',
'The exact failing string is ERR_CALLBACK_TIMEOUT_991 and it is unrelated to the redirect loop.',
'The temporary fix was rotating the staging secret and deleting a bad feature-flag row.',
'Do not write a generic login-debugging playbook from this.',
'This only applied to the callback timeout during the April rollout.',
'The successful fix was specific to the stale secret in staging.',
'It does not define a durable repo workflow for future tasks.',
'After rotating the secret, the callback timeout stopped reproducing.',
'Treat this as incident response only, not a reusable skill.',
'Once staging passed again, we closed TICKET-991.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated).toEqual([]);
expect(skillBodies).toEqual([]);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated project-specific workflow into a skill',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'settings-docs-regen-1',
summary: 'Update settings docs after adding a config option',
timestampOffsetMinutes: 420,
userTurns: [
'When we add a new config option, we have to regenerate the settings docs in a specific order.',
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
'Do not hand-edit generated settings docs.',
'If predocs is skipped, the generated schema docs miss the new defaults.',
'Update the source first, then run that generation sequence.',
'After regenerating, verify the schema output and docs changed together.',
'We used this same sequence the last time we touched settings docs.',
'That ordered workflow passed and produced the expected generated files.',
'Please keep the exact command order because reversing it breaks the output.',
'Yes, the generated settings docs were correct after those three commands.',
],
},
{
sessionId: 'settings-docs-regen-2',
summary: 'Regenerate settings schema docs for another new setting',
timestampOffsetMinutes: 360,
userTurns: [
'We are touching another setting, so follow the same settings-doc regeneration workflow again.',
'Run npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
'The project keeps generated settings docs in sync through those commands, not manual edits.',
'Skipping predocs caused stale defaults in the generated output before.',
'Change the source, then execute the same three commands in order.',
'Verify both the schema artifact and docs update together after regeneration.',
'This is the recurring workflow we use whenever a setting changes.',
'The exact order worked again on this second settings update.',
'Please preserve that ordering constraint for future settings changes.',
'Confirmed: the settings docs regenerated correctly with the same command sequence.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run predocs:settings');
expect(combinedSkills).toContain('npm run schema:settings');
expect(combinedSkills).toContain('npm run docs:settings');
expect(combinedSkills).toMatch(/Verification/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated multi-step migration workflow with ordering constraints',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'db-migration-v12',
summary: 'Run database migration for v12 schema update',
timestampOffsetMinutes: 420,
userTurns: [
'Every time we change the database schema we follow a specific migration workflow.',
'First run npm run db:check to verify no pending migrations conflict.',
'Then run npm run db:migrate to apply the new migration files.',
'After migration, always run npm run db:validate to confirm schema integrity.',
'If db:validate fails, immediately run npm run db:rollback before anything else.',
'Never skip db:check — last time we did, two migrations collided and corrupted the index.',
'The ordering is critical: check, migrate, validate. Reversing migrate and validate caused silent data loss before.',
'This v12 migration passed after following that exact sequence.',
'We use this same three-step workflow every time the schema changes.',
'Confirmed: db:check, db:migrate, db:validate completed successfully for v12.',
],
},
{
sessionId: 'db-migration-v13',
summary: 'Run database migration for v13 schema update',
timestampOffsetMinutes: 360,
userTurns: [
'New schema change for v13, following the same database migration workflow as before.',
'Start with npm run db:check to ensure no conflicting pending migrations.',
'Then npm run db:migrate to apply the v13 migration files.',
'Then npm run db:validate to confirm the schema is consistent.',
'If validation fails, run npm run db:rollback immediately — do not attempt manual fixes.',
'We learned the hard way that skipping db:check causes index corruption.',
'The check-migrate-validate order is mandatory for every schema change.',
'This is the same recurring workflow we used for v12 and earlier migrations.',
'The v13 migration passed with the same three-step sequence.',
'Confirmed: the standard db migration workflow succeeded again for v13.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run db:check');
expect(combinedSkills).toContain('npm run db:migrate');
expect(combinedSkills).toContain('npm run db:validate');
expect(combinedSkills).toMatch(/rollback/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
import { evalTest, TEST_AGENTS } from './test-helper.js';
describe('subtask delegation eval test cases', () => {
/**
* Checks that the main agent can correctly decompose a complex, sequential
* task into subtasks using the task tracker and delegate each to the appropriate expert subagent.
*
* The task requires:
* 1. Reading requirements (researcher)
* 2. Implementing logic (developer)
* 3. Documenting (doc expert)
*/
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please read the requirements in requirements.txt using a researcher, then implement the requested logic in src/logic.ts using a developer, and finally document the implementation in docs/logic.md using a documentation expert.',
files: {
'.gemini/agents/researcher.md': `---
name: researcher
description: Expert in reading files and extracting requirements.
tools:
- read_file
---
You are the researcher. Read the provided file and extract requirements.`,
'.gemini/agents/developer.md': `---
name: developer
description: Expert in implementing logic in TypeScript.
tools:
- write_file
---
You are the developer. Implement the requested logic in the specified file.`,
'.gemini/agents/doc-expert.md': `---
name: doc-expert
description: Expert in writing technical documentation.
tools:
- write_file
---
You are the doc expert. Document the provided implementation clearly.`,
'requirements.txt':
'Implement a function named "calculateSum" that adds two numbers.',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
'researcher',
'developer',
'doc-expert',
]);
const logicFile = rig.readFile('src/logic.ts');
const docFile = rig.readFile('docs/logic.md');
expect(logicFile).toContain('calculateSum');
expect(docFile).toBeTruthy();
},
});
/**
* Checks that the main agent can delegate a batch of independent subtasks
* to multiple subagents in parallel using the task tracker to manage state.
*/
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should delegate independent subtasks to specialists using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please update the project for internationalization (i18n), audit the security of the current code, and update the CSS to use a blue theme. Use specialized experts for each task.',
files: {
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
'index.ts': 'console.log("Hello World");',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
]);
},
});
});
+2
View File
@@ -119,6 +119,8 @@ describe('tracker_mode', () => {
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should correctly identify the task tracker storage location from the system prompt',
params: {
settings: { experimental: { taskTracker: true } },
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"paths": {
"@google/gemini-cli-core": ["../packages/core/index.ts"],
"@google/gemini-cli": ["../packages/cli/index.ts"]
}
},
"include": ["**/*.ts"],
"exclude": ["logs"],
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
}
+2
View File
@@ -7,6 +7,8 @@
import { evalTest, TestRig } from './test-helper.js';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'Reproduction: Agent uses Object.create() for cloning/delegation',
prompt:
'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.',
+13 -1
View File
@@ -21,6 +21,8 @@ describe('update_topic_behavior', () => {
* more than 1/4 turns.
*/
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'update_topic should be used at start, end and middle for complex tasks',
prompt: `Create a simple users REST API using Express.
1. Initialize a new npm project and install express.
@@ -117,6 +119,8 @@ describe('update_topic_behavior', () => {
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'update_topic should NOT be used for informational coding tasks (Obvious)',
approvalMode: 'default',
prompt:
@@ -142,6 +146,8 @@ describe('update_topic_behavior', () => {
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'update_topic should NOT be used for surgical symbol searches (Grey Area)',
approvalMode: 'default',
prompt:
@@ -169,6 +175,8 @@ describe('update_topic_behavior', () => {
});
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'update_topic should be used for medium complexity multi-step tasks',
prompt:
'Refactor the `users-api` project. Move the routing logic from src/app.ts into a new file src/routes.ts, and update app.ts to use the new routes file.',
@@ -212,7 +220,9 @@ export default app;
expect(topicCalls.length).toBeGreaterThanOrEqual(2);
// Verify it actually did the refactoring to ensure it didn't just fail immediately
expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true);
expect(fs.existsSync(path.join(rig.testDir!, 'src/routes.ts'))).toBe(
true,
);
},
});
@@ -224,6 +234,8 @@ export default app;
* the prompt change that improves the behavior.
*/
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'update_topic should not be called twice in a row',
prompt: `
We need to build a C compiler.
+5 -1
View File
@@ -39,7 +39,11 @@ describe('web-fetch rate limiting', () => {
const rateLimitedCalls = toolLogs.filter(
(log) =>
log.toolRequest.name === 'web_fetch' &&
log.toolRequest.error?.includes('Rate limit exceeded'),
(
('error' in log.toolRequest
? (log.toolRequest as unknown as Record<string, string>)['error']
: '') as string
)?.includes('Rate limit exceeded'),
);
expect(rateLimitedCalls.length).toBeGreaterThan(0);
+2 -1
View File
@@ -164,7 +164,8 @@ describe.skipIf(skipFlaky)(
);
expect(blockHook).toBeDefined();
expect(
blockHook?.hookCall.stdout + blockHook?.hookCall.stderr,
(blockHook?.hookCall.stdout || '') +
(blockHook?.hookCall.stderr || ''),
).toContain(blockMsg);
});
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
@@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
+178
View File
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
describe('mcp-resources-integration', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should list mcp resources', async () => {
await rig.setup('mcp-list-resources-test', {
settings: {
model: {
name: 'gemini-3-flash-preview',
},
},
fakeResponsesPath: join(__dirname, 'mcp-list-resources.responses'),
});
// Workaround for ProjectRegistry save issue
const userGeminiDir = join(rig.homeDir!, '.gemini');
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
// Add a dummy server to get setup done
rig.addTestMcpServer('resource-server', {
name: 'resource-server',
tools: [],
});
// Overwrite the script with resource support
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
const scriptContent = `
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListResourcesRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{
name: 'resource-server',
version: '1.0.0',
},
{
capabilities: {
resources: {},
},
},
);
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: 'test://resource1',
name: 'Resource 1',
mimeType: 'text/plain',
description: 'A test resource',
}
],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
`;
fs.writeFileSync(scriptPath, scriptContent);
const output = await rig.run({
args: 'List all available MCP resources.',
env: { GEMINI_API_KEY: 'dummy' },
});
const foundCall = await rig.waitForToolCall('list_mcp_resources');
expect(foundCall).toBeTruthy();
expect(output).toContain('test://resource1');
}, 60000);
it('should read mcp resource', async () => {
await rig.setup('mcp-read-resource-test', {
settings: {
model: {
name: 'gemini-3-flash-preview',
},
},
fakeResponsesPath: join(__dirname, 'mcp-read-resource.responses'),
});
// Workaround for ProjectRegistry save issue
const userGeminiDir = join(rig.homeDir!, '.gemini');
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
// Add a dummy server to get setup done
rig.addTestMcpServer('resource-server', {
name: 'resource-server',
tools: [],
});
// Overwrite the script with resource support
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
const scriptContent = `
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{
name: 'resource-server',
version: '1.0.0',
},
{
capabilities: {
resources: {},
},
},
);
// Need to provide list resources so the tool is active!
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: 'test://resource1',
name: 'Resource 1',
mimeType: 'text/plain',
description: 'A test resource',
}
],
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === 'test://resource1') {
return {
contents: [
{
uri: 'test://resource1',
mimeType: 'text/plain',
text: 'This is the content of resource 1',
}
],
};
}
throw new Error('Resource not found');
});
const transport = new StdioServerTransport();
await server.connect(transport);
`;
fs.writeFileSync(scriptPath, scriptContent);
const output = await rig.run({
args: 'Read the MCP resource test://resource1.',
env: { GEMINI_API_KEY: 'dummy' },
});
const foundCall = await rig.waitForToolCall('read_mcp_resource');
expect(foundCall).toBeTruthy();
expect(output).toContain('content of resource 1');
}, 60000);
});
+16 -5
View File
@@ -108,7 +108,7 @@ describe('Plan Mode', () => {
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
).toBe(true);
});
@@ -221,7 +221,7 @@ describe('Plan Mode', () => {
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
).toBe(true);
});
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
@@ -270,13 +270,24 @@ describe('Plan Mode', () => {
);
const apiRequests = rig.readAllApiRequest();
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
const modelNames = apiRequests.map(
(r) =>
('model' in (r.attributes || {})
? (r.attributes as unknown as Record<string, string>)['model']
: 'unknown') || 'unknown',
);
const proRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('pro'),
('model' in (r.attributes || {})
? (r.attributes as unknown as Record<string, string>)['model']
: 'unknown'
)?.includes('pro'),
);
const flashRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('flash'),
('model' in (r.attributes || {})
? (r.attributes as unknown as Record<string, string>)['model']
: 'unknown'
)?.includes('flash'),
);
expect(
+5 -1
View File
@@ -5,5 +5,9 @@
"allowJs": true
},
"include": ["**/*.ts"],
"references": [{ "path": "../packages/core" }]
"references": [
{ "path": "../packages/core" },
{ "path": "../packages/test-utils" },
{ "path": "../packages/cli" }
]
}
+6 -2
View File
@@ -489,8 +489,12 @@ async function generateSharedLargeChatData(tempDir: string) {
// Wait for streams to finish
await Promise.all([
new Promise((res) => activeResponsesStream.on('finish', res)),
new Promise((res) => resumeResponsesStream.on('finish', res)),
new Promise((res) =>
activeResponsesStream.on('finish', () => res(undefined)),
),
new Promise((res) =>
resumeResponsesStream.on('finish', () => res(undefined)),
),
]);
return {
+834 -241
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -62,7 +62,7 @@
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",
@@ -94,6 +94,7 @@
],
"devDependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"read-package-up": "^11.0.0",
"@octokit/rest": "^22.0.0",
"@types/marked": "^5.0.2",
"@types/mime-types": "^3.0.1",
@@ -137,6 +138,7 @@
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
+37
View File
@@ -278,6 +278,24 @@ describe('parseArguments', () => {
});
});
describe('knowledgeSource', () => {
it('should parse --knowledge-source flag with a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'mykb.md'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe('mykb.md');
});
it('should default to ~/.agents/kb.md when --knowledge-source is provided without a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe(
path.join(os.homedir(), '.agents', 'kb.md'),
);
});
});
it.each([
{
description: 'long flags',
@@ -909,6 +927,25 @@ describe('loadCliConfig', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should enable simulateUser when knowledgeSource is provided', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'k.txt'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
expect(config.getKnowledgeSource()).toBe(
path.resolve(process.cwd(), 'k.txt'),
);
});
it('should enable simulateUser when simulateUser flag is provided', async () => {
process.argv = ['node', 'script.js', '--simulate-user'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
});
it('should be non-interactive when isCommand is set', async () => {
process.argv = ['node', 'script.js', 'mcp', 'list'];
const argv = await parseArguments(createTestMergedSettings());
+31
View File
@@ -8,6 +8,7 @@ import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import * as os from 'node:os';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
@@ -79,6 +80,7 @@ export interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
debug: boolean | undefined;
disableStreaming?: boolean;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
@@ -106,6 +108,8 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
knowledgeSource: string | undefined;
}
/**
@@ -418,6 +422,10 @@ export async function parseArguments(
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
})
.option('disable-streaming', {
type: 'boolean',
description: 'Disable streaming responses from the model',
})
.option('output-format', {
alias: 'o',
type: 'string',
@@ -443,6 +451,24 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('simulate-user', {
type: 'boolean',
description:
'Run the user simulation agent in the background for evaluation purposes.',
})
.option('knowledge-source', {
type: 'string',
skipValidation: true,
description:
'A file path to load into the user simulator context and update with new knowledge. Defaults to ~/.agents/kb.md if passed without a value.',
coerce: (value: string): string => {
const trimmed = value.trim();
if (trimmed === '') {
return path.join(os.homedir(), '.agents', 'kb.md');
}
return trimmed;
},
}),
)
.version(await getVersion()) // This will enable the --version flag based on package.json
@@ -897,6 +923,7 @@ export async function loadCliConfig(
return new Config({
acpMode: isAcpMode,
clientName,
disableStreaming: argv.disableStreaming,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -948,6 +975,10 @@ export async function loadCliConfig(
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
simulateUser: !!argv.simulateUser || !!argv.knowledgeSource,
knowledgeSource: argv.knowledgeSource
? path.resolve(cwd, resolvePath(argv.knowledgeSource))
: undefined,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
+17 -2
View File
@@ -256,14 +256,29 @@ const SETTINGS_SCHEMA = {
},
enableNotifications: {
type: 'boolean',
label: 'Enable Notifications',
label: 'Enable Terminal Notifications',
category: 'General',
requiresRestart: false,
default: false,
description:
'Enable run-event notifications for action-required prompts and session completion.',
'Enable terminal run-event notifications for action-required prompts and session completion.',
showInDialog: true,
},
notificationMethod: {
type: 'enum',
label: 'Terminal Notification Method',
category: 'General',
requiresRestart: false,
default: 'auto',
description: 'How to send terminal notifications.',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'osc9', label: 'OSC 9' },
{ value: 'osc777', label: 'OSC 777' },
{ value: 'bell', label: 'Bell' },
],
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
+4
View File
@@ -555,6 +555,8 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
@@ -613,6 +615,8 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
+41 -4
View File
@@ -9,11 +9,19 @@ import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { UserSimulator } from './services/UserSimulator.js';
import {
registerCleanup,
removeCleanup,
setupTtyCheck,
} from './utils/cleanup.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -135,6 +143,12 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulateUser = config.getSimulateUser();
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -146,12 +160,20 @@ export async function startInteractiveUI(
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
onRender: (metrics: RenderMetrics) => {
lastFrame = metrics.output;
if (metrics.staticOutput) {
staticHistory.push(metrics.staticOutput);
if (staticHistory.length > 50) {
staticHistory.shift();
}
}
if (metrics.renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, metrics.renderTime);
}
profiler.reportFrameRendered();
},
@@ -188,6 +210,21 @@ export async function startInteractiveUI(
}
});
if (simulateUser) {
const simulator = new UserSimulator(
config,
() => {
if (lastFrame === undefined) return undefined;
// Combine history with latest frame for the simulator
const historyText = staticHistory.join('\n');
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
},
simulatedStdin,
);
simulator.start();
registerCleanup(() => simulator.stop());
}
const cleanupUnmount = () => instance.unmount();
registerCleanup(cleanupUnmount);
@@ -88,7 +88,7 @@ describe('SkillCommandLoader', () => {
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: 'test-skill' },
postSubmitPrompt: undefined,
postSubmitPrompt: 'Use the skill test-skill',
});
});
@@ -46,7 +46,10 @@ export class SkillCommandLoader implements ICommandLoader {
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: skill.name },
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
postSubmitPrompt:
args.trim().length > 0
? args.trim()
: `Use the skill ${skill.name}`,
}),
};
});
+352
View File
@@ -0,0 +1,352 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
debugLogger,
LlmRole,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
} from '@google/gemini-cli-core';
import type { Writable } from 'node:stream';
import * as fs from 'node:fs';
import * as path from 'node:path';
interface SimulatorResponse {
action?: string;
thought?: string;
used_knowledge?: boolean;
new_rule?: string;
}
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastScreenContent = '';
private isProcessing = false;
private interactionsFile: string | null = null;
private knowledgeBase = '';
private editableKnowledgeFile: string | null = null;
private actionHistory: string[] = [];
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
if (!this.config.getSimulateUser()) {
return;
}
const source = this.config.getKnowledgeSource?.();
if (source) {
if (!fs.existsSync(source)) {
try {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.writeFileSync(source, '', 'utf8');
} catch (e) {
debugLogger.error(`Failed to create knowledge file at ${source}`, e);
}
}
this.editableKnowledgeFile = source;
this.loadKnowledge(source);
}
this.interactionsFile = `interactions_${Date.now()}.txt`;
this.isRunning = true;
this.timer = setInterval(() => this.tick(), 1000);
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
debugLogger.log('User simulator stopped');
}
private loadKnowledge(p: string) {
try {
if (!fs.existsSync(p)) return;
const stats = fs.statSync(p);
if (stats.isFile()) {
const content = fs.readFileSync(p, 'utf-8');
if (content.trim()) {
this.knowledgeBase = content + '\n';
}
}
} catch (e) {
debugLogger.error(`Failed to load knowledge from ${p}`, e);
}
}
private async tick() {
if (!this.isRunning || this.isProcessing) return;
try {
this.isProcessing = true;
const screen = this.getScreen();
if (!screen) return;
const strippedScreen = screen
.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
'',
)
.replace(/\n([ \t]*\n)+/g, '\n\n');
const normalizedScreen = strippedScreen
.replace(/[\u2800-\u28FF]/g, '')
.replace(/[|/-\\]/g, '')
.replace(/\b\d+(\.\d+)?s\b/g, '')
.replace(/\b\d+m(\s+\d+s)?\b/g, '')
.replace(/\(\s*\)/g, '')
.trim();
if (normalizedScreen === this.lastScreenContent) return;
debugLogger.log(
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
);
}
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const originalGoal = this.config.getQuestion();
const goalInstruction = originalGoal
? `\nThe original goal was: "${originalGoal}"\n`
: '';
const knowledgeInstruction = this.knowledgeBase
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
: '';
const historyInstruction =
this.actionHistory.length > 0
? `\nRecent Simulator Actions (last 10):\n${this.actionHistory
.slice(-10)
.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`)
.join('\n')}\n`
: '';
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
Look carefully at the screen and determine the CLI's current state:
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
- In this case, your action MUST be exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
- If the task is indeed fully complete, your action should be "Thank you\\r" to graciously finish the simulation.
- If you have already said thank you, your action MUST be exactly: <DONE>
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
- Your action should be the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
CRITICAL RULES:
- RULE 1: If there is ANY active spinner (e.g., , , , , , , ) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. Your action MUST be <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: If prompted to allow execution of a command with options like 'Allow once' and 'Allow for this session', you MUST choose the option for 'Allow for this session' (typically by sending '2\\r').
- RULE 4: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
JSON FORMAT:
{
"action": "<The exact raw characters to send, <WAIT>, or <DONE>>",
"used_knowledge": <true if you used the User Knowledge Base below to answer this prompt, false otherwise>,
"new_rule": "<If used_knowledge is false and action is not <WAIT> or <DONE>, formulate a single, clear, reusable one-line rule combining the question and your answer without using option numbers (e.g. 1, 2) that might change. For example: 'If asked to allow pip execution, always allow it.' or 'Automatically accept edits for snake game implementation.'>"
}
${goalInstruction}${knowledgeInstruction}${historyInstruction}
Here is the current terminal screen output:
<screen>
${strippedScreen}
</screen>`;
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
);
}
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useGemini3_1FlashLite
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-prompt',
LlmRole.UTILITY_SIMULATOR,
);
let responseText = '';
let parsedJson: SimulatorResponse = {};
try {
let cleanJson = response.text || '';
const startIdx = cleanJson.indexOf('{');
const endIdx = cleanJson.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
cleanJson = cleanJson.substring(startIdx, endIdx + 1);
} else {
cleanJson = cleanJson.replace(/^```json\s*|\s*```$/gm, '').trim();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsedJson = JSON.parse(cleanJson) as SimulatorResponse;
responseText = parsedJson.action || '';
} catch (err) {
debugLogger.error('Failed to parse simulator response as JSON', err);
const text = (response.text || '').trim();
if (
text === '<WAIT>' ||
text === '<DONE>' ||
/^\d+\\r$/.test(text) ||
text === '\\r'
) {
responseText = text.replace(/^[`"']+|[`"']+$/g, '');
} else {
responseText = ''; // Prevent typing broken JSON string
}
}
const trimmedResponse = responseText.trim();
debugLogger.log(
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
);
}
debugLogger.log(
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
);
if (trimmedResponse === '<DONE>') {
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
debugLogger.log(msg);
if (this.interactionsFile) {
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
}
// eslint-disable-next-line no-console
console.log(`\n${msg}`);
this.stop();
process.exit(0);
}
if (trimmedResponse === '<WAIT>') {
debugLogger.log(
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
);
this.actionHistory.push('<WAIT>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<WAIT>"\n\n`,
);
}
this.lastScreenContent = normalizedScreen;
return;
}
if (responseText) {
const keys = responseText
.replace(/\\n|\n/g, '\r')
.replace(/\\r/g, '\r');
debugLogger.log(
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
);
this.actionHistory.push(keys);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: ${JSON.stringify(keys)}\n\n`,
);
}
if (
!parsedJson.used_knowledge &&
parsedJson.new_rule &&
this.editableKnowledgeFile
) {
const newKnowledge = `- ${parsedJson.new_rule}\n`;
this.knowledgeBase += newKnowledge;
try {
fs.appendFileSync(this.editableKnowledgeFile, newKnowledge);
debugLogger.log(
`[SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}\n\n`,
);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
}
for (const char of keys) {
if (char === '\r') {
// Wait a bit to ensure the previous character is rendered before submitting
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.stdinBuffer.write(char);
// Small delay to ensure Ink processes each keypress event individually
// while preventing UI state collisions during long simulated inputs.
await new Promise((resolve) => setTimeout(resolve, 5));
}
this.lastScreenContent = normalizedScreen;
} else {
debugLogger.log('[SIMULATOR] Skipping (empty response)');
this.actionHistory.push('<EMPTY>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<EMPTY>"\n\n`,
);
}
this.lastScreenContent = normalizedScreen;
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
}
@@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
resetNewSessionState: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getWorktreeSettings: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
@@ -64,6 +65,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
getSimulateUser: vi.fn(() => false),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
@@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => ({
const terminalNotificationsMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
isNotificationsEnabled: vi.fn(() => true),
getNotificationMethod: vi.fn(() => 'auto'),
buildRunEventNotificationContent: vi.fn((event) => ({
title: 'Mock Notification',
subtitle: 'Mock Subtitle',
@@ -194,6 +195,7 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
vi.mock('../utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
getNotificationMethod: terminalNotificationsMocks.getNotificationMethod,
buildRunEventNotificationContent:
terminalNotificationsMocks.buildRunEventNotificationContent,
}));
+12 -1
View File
@@ -181,7 +181,10 @@ import { useTimedMessage } from './hooks/useTimedMessage.js';
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
import {
isNotificationsEnabled,
getNotificationMethod,
} from '../utils/terminalNotifications.js';
import {
getLastTurnToolCallIds,
isToolExecuting,
@@ -225,6 +228,7 @@ export const AppContainer = (props: AppContainerProps) => {
const settings = useSettings();
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const notificationMethod = getNotificationMethod(settings);
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
useContext(InkAppContext);
@@ -1403,7 +1407,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
debugLogger.log(
`[AppContainer] handleFinalSubmit: streamingState=${streamingState}, isIdle=${isIdle}, isSlash=${isSlash}`,
);
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
debugLogger.log(
`[AppContainer] handleFinalSubmit: condition met, calling submitQuery`,
);
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -2284,6 +2294,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
useRunEventNotifications({
notificationsEnabled,
notificationMethod,
isFocused,
hasReceivedFocusEvent,
streamingState,
@@ -39,7 +39,7 @@ describe('clearCommand', () => {
agentContext: {
config: {
getEnableHooks: vi.fn().mockReturnValue(false),
setSessionId: vi.fn(),
resetNewSessionState: vi.fn(),
getMessageBus: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue({
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
@@ -74,6 +74,9 @@ describe('clearCommand', () => {
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockHintClear).toHaveBeenCalledTimes(1);
expect(
mockContext.services.agentContext?.config.resetNewSessionState,
).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.clear).toHaveBeenCalled();
expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
+1 -1
View File
@@ -39,7 +39,7 @@ export const clearCommand: SlashCommand = {
let newSessionId: string | undefined;
if (config) {
newSessionId = randomUUID();
config.setSessionId(newSessionId);
config.resetNewSessionState(newSessionId);
}
if (geminiClient) {
@@ -868,24 +868,28 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
: undefined;
// Reserve space for at least 3 items if more selectionItems available.
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
const questionHeightLimit =
listHeight && !isAlternateBuffer
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
: Math.min(
30,
Math.max(1, listHeight - Math.min(selectionItems.length, 5) * 2),
)
: undefined;
const maxItemsToShow =
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
? Math.min(
selectionItems.length,
Math.max(
1,
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
),
)
: selectionItems.length;
let maxItemsToShow = selectionItems.length;
if (listHeight && (!isAlternateBuffer || availableHeight !== undefined)) {
if (selectionItems.length <= 5) {
maxItemsToShow = selectionItems.length;
} else {
maxItemsToShow = Math.min(
selectionItems.length,
Math.max(1, Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2)),
);
}
}
return (
<Box flexDirection="column">
@@ -79,6 +79,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
debugLogger.debug('usePlanContent loading plan:', planPath);
const load = async () => {
try {
@@ -125,6 +126,10 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
return;
}
debugLogger.debug(
'usePlanContent loaded successfully, length:',
content.length,
);
setState({ status: PlanStatus.Loaded, content });
} catch (err: unknown) {
if (ignore) return;
@@ -375,6 +375,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
debugLogger.log(`[InputPrompt] handleSubmitAndClear: \${submittedValue}`);
let processedValue = submittedValue;
if (buffer.pastedContent) {
processedValue = expandPastePlaceholders(
@@ -425,6 +426,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmit = useCallback(
(submittedValue: string) => {
debugLogger.log(`[InputPrompt] handleSubmit: \${submittedValue}`);
const trimmedMessage = submittedValue.trim();
const isSlash = isSlashCommand(trimmedMessage);
@@ -649,6 +651,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
debugLogger.log(
`[UI INPUT] handleInput received key: ${JSON.stringify(key)}`,
);
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -1214,9 +1219,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.SUBMIT](key)) {
debugLogger.log(
`[InputPrompt] Command.SUBMIT matched, buffer.text="${buffer.text}"`,
);
if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission
if (recentUnsafePasteTime !== null) {
debugLogger.log(
`[InputPrompt] Command.SUBMIT ignored due to recentUnsafePasteTime`,
);
// Paste occurred recently in a terminal where we don't trust pastes
// to be reported correctly so assume this paste was really a
// newline that was part of the paste.
@@ -1234,8 +1245,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.backspace();
buffer.newline();
} else {
debugLogger.log(
`[InputPrompt] Calling handleSubmit from handleInput`,
);
handleSubmit(buffer.text);
}
} else {
debugLogger.log(
`[InputPrompt] Command.SUBMIT ignored because buffer is empty`,
);
}
return true;
}
@@ -14,12 +14,24 @@ Spinner Working...
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
"
Spinner Working...
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Working...
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -56,47 +56,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -67,47 +67,47 @@
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -19,8 +19,11 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -65,8 +65,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -111,8 +111,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable Auto Update true* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -157,8 +157,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -203,8 +203,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -249,8 +249,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -295,8 +295,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -341,8 +341,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -387,8 +387,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable Terminal Notifications false │
│ Enable terminal run-event notifications for action-required prompts and session com
│ │
│ Terminal Notification Method Auto │
│ How to send terminal notifications. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
@@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -11,12 +11,12 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -34,14 +34,14 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. Google Code Light │ 4 for _ in range(n): │ │
│ 6. Solarized Light │ 5 a, b = b, a + b │ │
│ 7. Xcode Light │ 6 return a │ │
│ 8. ANSI Dark (Incompatible) │ │ │
│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Dark (Incompatible) │ │ │
│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
│ 7. Solarized Light │ 6 return a │ │
│ 8. Xcode Light │ │ │
│ 9. ANSI Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Atom One Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Dark (Incompatible) │ │ │
│ 12. Default Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -61,12 +61,12 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -100,12 +100,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -125,12 +125,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
@@ -246,11 +246,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
(showClosingBorder ? 1 : 0);
} else if (isTopicToolCall) {
// Topic Message Spacing Breakdown:
// 1. Top Margin (1): Always present for spacing.
// 2. Topic Content (1).
// 3. Bottom Margin (1): Always present around TopicMessage for breathing room.
// 4. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
height += 1 + 1 + 1 + (showClosingBorder ? 1 : 0);
// 1. Topic Content (1).
// 2. Bottom Margin (1): Always present around TopicMessage for breathing room.
// 3. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
height += 1 + 1 + (showClosingBorder ? 1 : 0);
} else if (isCompact) {
// Compact Tool: Always renders as a single dense line.
height += 1;
@@ -439,7 +438,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
{isCompact ? (
<DenseToolMessage {...commonProps} />
) : isTopicToolCall ? (
<Box marginTop={1} marginBottom={1}>
<Box marginBottom={1}>
<TopicMessage {...commonProps} />
</Box>
) : isShellToolCall ? (
@@ -77,8 +77,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
"
Testing Topic: This is the description
" Testing Topic: This is the description
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
@@ -143,8 +142,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
"
Testing Topic: This is the description
" Testing Topic: This is the description
"
`;
@@ -8,6 +8,7 @@ import type React from 'react';
import { useCallback, useRef } from 'react';
import { Text, Box, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { debugLogger } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
@@ -56,6 +57,9 @@ export function TextInput({
const handleKeyPress = useCallback(
(key: Key) => {
debugLogger.log(
`[TEXT INPUT] handleKeyPress received key: ${JSON.stringify(key)}`,
);
if (key.name === 'escape' && onCancel) {
onCancel();
return true;
@@ -873,8 +873,15 @@ export function KeypressProvider({
process.stdin.setEncoding('utf8'); // Make data events emit strings
debugLogger.log(
`[DEBUG] KeypressProvider simulateUser: ${config?.getSimulateUser()}`,
);
let processor = nonKeyboardEventFilter(broadcast);
if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
if (
!terminalCapabilityManager.isKittyProtocolEnabled() &&
!config?.getSimulateUser()
) {
processor = bufferFastReturn(processor);
}
processor = bufferBackslashEnter(processor);
@@ -858,11 +858,81 @@ describe('useSlashCommandProcessor', () => {
});
describe('Lifecycle', () => {
it('removes the IDE status listener on unmount after async initialization', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
result.unmount();
unmountHook = undefined;
expect(addStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledWith(
addStatusChangeListener.mock.calls[0]?.[0],
);
});
it('does not register an IDE status listener if unmounted before async initialization resolves', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
result.unmount();
unmountHook = undefined;
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
expect(addStatusChangeListener).not.toHaveBeenCalled();
expect(removeStatusChangeListener).not.toHaveBeenCalled();
});
it('should abort command loading when the hook unmounts', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { unmount } = await setupProcessorHook();
unmount();
unmountHook = undefined;
expect(abortSpy).toHaveBeenCalledTimes(1);
});
@@ -281,10 +281,16 @@ export const useSlashCommandProcessor = (
const listener = () => {
reloadCommands();
};
let isActive = true;
let activeIdeClient: IdeClient | undefined;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
if (!isActive) {
return;
}
activeIdeClient = ideClient;
ideClient.addStatusChangeListener(listener);
})();
@@ -307,11 +313,8 @@ export const useSlashCommandProcessor = (
coreEvents.on('extensionsStopping', extensionEventListener);
return () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
ideClient.removeStatusChangeListener(listener);
})();
isActive = false;
activeIdeClient?.removeStatusChangeListener(listener);
removeMCPStatusChangeListener(listener);
coreEvents.off('extensionsStarting', extensionEventListener);
coreEvents.off('extensionsStopping', extensionEventListener);
@@ -15,12 +15,14 @@ import { getPendingAttentionNotification } from '../utils/pendingAttentionNotifi
import {
buildRunEventNotificationContent,
notifyViaTerminal,
type TerminalNotificationMethod,
} from '../../utils/terminalNotifications.js';
const ATTENTION_NOTIFICATION_COOLDOWN_MS = 20_000;
interface RunEventNotificationParams {
notificationsEnabled: boolean;
notificationMethod: TerminalNotificationMethod;
isFocused: boolean;
hasReceivedFocusEvent: boolean;
streamingState: StreamingState;
@@ -36,6 +38,7 @@ interface RunEventNotificationParams {
export function useRunEventNotifications({
notificationsEnabled,
notificationMethod,
isFocused,
hasReceivedFocusEvent,
streamingState,
@@ -124,11 +127,13 @@ export function useRunEventNotifications({
void notifyViaTerminal(
notificationsEnabled,
buildRunEventNotificationContent(pendingAttentionNotification.event),
notificationMethod,
);
}, [
isFocused,
hasReceivedFocusEvent,
notificationsEnabled,
notificationMethod,
pendingAttentionNotification,
]);
@@ -159,12 +164,14 @@ export function useRunEventNotifications({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
notificationMethod,
);
}, [
streamingState,
isFocused,
hasReceivedFocusEvent,
notificationsEnabled,
notificationMethod,
hasPendingActionRequired,
]);
}
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubDarkColorblindColors: ColorsTheme = {
type: 'dark',
Background: '#0d1117',
Foreground: '#e6edf3',
LightBlue: '#a5d6ff',
AccentBlue: '#79c0ff',
AccentPurple: '#d2a8ff',
AccentCyan: '#a5d6ff',
AccentGreen: '#a5d6ff',
AccentYellow: '#d29922',
AccentRed: '#f0883e',
DiffAdded: '#0d161f',
DiffRemoved: '#1d150e',
Comment: '#7d8590',
Gray: '#7d8590',
DarkGray: interpolateColor('#7d8590', '#0d1117', 0.5),
GradientColors: ['#58a6ff', '#f0883e'],
};
export const GitHubDarkColorblind: Theme = new Theme(
'GitHub Dark Colorblind',
'dark',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubDarkColorblindColors.Foreground,
background: githubDarkColorblindColors.Background,
},
'hljs-comment': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-number': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubDarkColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubDarkColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#682d0f',
color: githubDarkColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#0c2d6b',
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubDarkColorblindColors,
);
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubLightColorblindColors: ColorsTheme = {
type: 'light',
Background: '#ffffff',
Foreground: '#1f2328',
LightBlue: '#0a3069',
AccentBlue: '#0550ae',
AccentPurple: '#8250df',
AccentCyan: '#0a3069',
AccentGreen: '#0969da',
AccentYellow: '#9a6700',
AccentRed: '#bc4c00',
DiffAdded: '#ddf4ff',
DiffRemoved: '#fff1e5',
Comment: '#656d76',
Gray: '#656d76',
DarkGray: interpolateColor('#656d76', '#ffffff', 0.5),
GradientColors: ['#0969da', '#bc4c00'],
};
export const GitHubLightColorblind: Theme = new Theme(
'GitHub Light Colorblind',
'light',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubLightColorblindColors.Foreground,
background: githubLightColorblindColors.Background,
},
'hljs-comment': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubLightColorblindColors.Foreground,
},
'hljs-number': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubLightColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubLightColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#fff1e5',
color: githubLightColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#ddf4ff',
color: githubLightColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubLightColorblindColors,
);
@@ -10,6 +10,8 @@ import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
import { Dracula } from './builtin/dark/dracula-dark.js';
import { GitHubDark } from './builtin/dark/github-dark.js';
import { GitHubLight } from './builtin/light/github-light.js';
import { GitHubDarkColorblind } from './builtin/dark/github-dark-colorblind.js';
import { GitHubLightColorblind } from './builtin/light/github-light-colorblind.js';
import { GoogleCode } from './builtin/light/googlecode-light.js';
import { Holiday } from './builtin/dark/holiday-dark.js';
import { DefaultLight } from './builtin/light/default-light.js';
@@ -79,6 +81,8 @@ class ThemeManager {
DefaultDark,
GitHubDark,
GitHubLight,
GitHubDarkColorblind,
GitHubLightColorblind,
GoogleCode,
Holiday,
ShadesOfPurple,
+4 -2
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
import React, { useMemo } from 'react';
import {
Text,
@@ -210,7 +212,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// Use the TIGHTEST widths that fit the wrapped content + padding
const adjustedWidths = actualColumnWidths.map(
(w) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
w + COLUMN_PADDING,
);
@@ -263,7 +265,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
isHeader = false,
): React.ReactNode => {
const renderedCells = cells.map((cell, index) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const width = adjustedWidths[index] || 0;
return renderCell(cell, width, isHeader);
});
@@ -365,76 +365,123 @@ describe('TerminalCapabilityManager', () => {
);
});
describe('supportsOsc9Notifications', () => {
describe('isTmux', () => {
const manager = TerminalCapabilityManager.getInstance();
it.each([
{
name: 'WezTerm (terminal name)',
terminalName: 'WezTerm',
env: {},
expected: true,
},
{
name: 'iTerm.app (terminal name)',
terminalName: 'iTerm.app',
env: {},
expected: true,
},
{
name: 'ghostty (terminal name)',
terminalName: 'ghostty',
env: {},
expected: true,
},
{
name: 'kitty (terminal name)',
terminalName: 'kitty',
env: {},
expected: true,
},
{
name: 'some-other-term (terminal name)',
terminalName: 'some-other-term',
env: {},
expected: false,
},
{
name: 'iTerm.app (TERM_PROGRAM)',
terminalName: undefined,
env: { TERM_PROGRAM: 'iTerm.app' },
expected: true,
},
{
name: 'vscode (TERM_PROGRAM)',
terminalName: undefined,
env: { TERM_PROGRAM: 'vscode' },
expected: false,
},
{
name: 'xterm-kitty (TERM)',
terminalName: undefined,
env: { TERM: 'xterm-kitty' },
expected: true,
},
{
name: 'xterm-256color (TERM)',
terminalName: undefined,
env: { TERM: 'xterm-256color' },
expected: false,
},
{
name: 'Windows Terminal (WT_SESSION)',
terminalName: 'iTerm.app',
env: { WT_SESSION: 'some-guid' },
expected: false,
},
])(
'should return $expected for $name',
({ terminalName, env, expected }) => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName);
expect(manager.supportsOsc9Notifications(env)).toBe(expected);
},
);
it('returns true when TMUX is set', () => {
expect(manager.isTmux({ TMUX: '1' })).toBe(true);
expect(manager.isTmux({ TMUX: 'tmux-1234' })).toBe(true);
});
it('returns false when TMUX is not set', () => {
expect(manager.isTmux({})).toBe(false);
expect(manager.isTmux({ STY: '1' })).toBe(false);
});
});
describe('isScreen', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when STY is set', () => {
expect(manager.isScreen({ STY: '1' })).toBe(true);
expect(manager.isScreen({ STY: 'screen.1234' })).toBe(true);
});
it('returns false when STY is not set', () => {
expect(manager.isScreen({})).toBe(false);
expect(manager.isScreen({ TMUX: '1' })).toBe(false);
});
});
describe('isITerm2', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when iTerm is in terminal name', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue('iTerm.app');
expect(manager.isITerm2({})).toBe(true);
});
it('returns true when TERM_PROGRAM is iTerm.app', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
expect(manager.isITerm2({ TERM_PROGRAM: 'iTerm.app' })).toBe(true);
});
it('returns false otherwise', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm');
expect(manager.isITerm2({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false);
});
});
describe('isAlacritty', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when ALACRITTY_WINDOW_ID is set', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
expect(manager.isAlacritty({ ALACRITTY_WINDOW_ID: '123' })).toBe(true);
});
it('returns true when TERM is alacritty', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
expect(manager.isAlacritty({ TERM: 'alacritty' })).toBe(true);
});
it('returns true when terminal name contains alacritty', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue('alacritty');
expect(manager.isAlacritty({})).toBe(true);
});
it('returns false otherwise', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
expect(manager.isAlacritty({ TERM: 'xterm' })).toBe(false);
});
});
describe('isAppleTerminal', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when apple_terminal is in terminal name', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue('apple_terminal');
expect(manager.isAppleTerminal({})).toBe(true);
});
it('returns true when TERM_PROGRAM is Apple_Terminal', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
expect(manager.isAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(
true,
);
});
it('returns false otherwise', () => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm');
expect(manager.isAppleTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe(
false,
);
});
});
describe('isVSCodeTerminal', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when TERM_PROGRAM is vscode', () => {
expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'vscode' })).toBe(true);
});
it('returns false otherwise', () => {
expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe(
false,
);
});
});
describe('isWindowsTerminal', () => {
const manager = TerminalCapabilityManager.getInstance();
it('returns true when WT_SESSION is set', () => {
expect(manager.isWindowsTerminal({ WT_SESSION: 'some-guid' })).toBe(true);
});
it('returns false otherwise', () => {
expect(manager.isWindowsTerminal({})).toBe(false);
});
});
});
@@ -284,31 +284,43 @@ export class TerminalCapabilityManager {
);
}
supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean {
if (env['WT_SESSION']) {
return false;
}
isTmux(env: NodeJS.ProcessEnv = process.env): boolean {
return !!env['TMUX'];
}
return (
this.hasOsc9TerminalSignature(this.getTerminalName()) ||
this.hasOsc9TerminalSignature(env['TERM_PROGRAM']) ||
this.hasOsc9TerminalSignature(env['TERM'])
isScreen(env: NodeJS.ProcessEnv = process.env): boolean {
return !!env['STY'];
}
isITerm2(env: NodeJS.ProcessEnv = process.env): boolean {
return !!(
this.getTerminalName()?.toLowerCase().includes('iterm') ||
env['TERM_PROGRAM']?.toLowerCase().includes('iterm')
);
}
private hasOsc9TerminalSignature(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = value.toLowerCase();
return (
normalized.includes('wezterm') ||
normalized.includes('ghostty') ||
normalized.includes('iterm') ||
normalized.includes('kitty')
isAlacritty(env: NodeJS.ProcessEnv = process.env): boolean {
return !!(
this.getTerminalName()?.toLowerCase().includes('alacritty') ||
env['ALACRITTY_WINDOW_ID'] ||
env['TERM']?.toLowerCase().includes('alacritty')
);
}
isAppleTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
return !!(
this.getTerminalName()?.toLowerCase().includes('apple_terminal') ||
env['TERM_PROGRAM']?.toLowerCase().includes('apple_terminal')
);
}
isVSCodeTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
return !!env['TERM_PROGRAM']?.toLowerCase().includes('vscode');
}
isWindowsTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
return !!env['WT_SESSION'];
}
}
export const terminalCapabilityManager =
+106 -1
View File
@@ -8,8 +8,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, exec, execFile, execSync } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { start_sandbox } from './sandbox.js';
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
import {
FatalSandboxError,
homedir,
type SandboxConfig,
} from '@google/gemini-cli-core';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { EventEmitter } from 'node:events';
@@ -133,6 +138,7 @@ describe('sandbox', () => {
afterEach(() => {
process.env = originalEnv;
process.argv = originalArgv;
vi.unstubAllEnvs();
});
describe('start_sandbox', () => {
@@ -171,6 +177,105 @@ describe('sandbox', () => {
);
});
it('should resolve custom seatbelt profile from user home directory', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes(
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
),
);
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.any(Array),
expect.objectContaining({ stdio: 'inherit' }),
);
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
expect(spawnArgs).toEqual(
expect.arrayContaining(['-f', expect.any(String)]),
);
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
expect(profileArg).toEqual(
expect.stringContaining(
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
),
);
});
it('should fall back to project .gemini directory when user profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
vi.mocked(fs.existsSync).mockImplementation((p) => {
const s = String(p);
return (
s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) &&
!s.includes(path.join(homedir(), '.gemini'))
);
});
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.any(Array),
expect.objectContaining({ stdio: 'inherit' }),
);
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
expect(spawnArgs).toEqual(
expect.arrayContaining(['-f', expect.any(String)]),
);
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
expect(profileArg).toEqual(
expect.stringContaining(
path.join('.gemini', 'sandbox-macos-custom-test.sb'),
),
);
expect(profileArg).not.toContain(homedir());
});
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockReturnValue(false);
+10 -2
View File
@@ -68,9 +68,17 @@ export async function start_sandbox(
let profileFile = fileURLToPath(
new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
);
// if profile name is not recognized, then look for file under project settings directory
// if profile name is not recognized, look in user-level ~/.gemini first,
// then fall back to project-level .gemini. path.basename() strips any
// directory separators to prevent path traversal via SEATBELT_PROFILE.
if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`);
const safeProfile = path.basename(profile);
const fileName = `sandbox-macos-${safeProfile}.sb`;
const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName);
const projectProfileFile = path.join(GEMINI_DIR, fileName);
profileFile = fs.existsSync(userProfileFile)
? userProfileFile
: projectProfileFile;
}
if (!fs.existsSync(profileFile)) {
throw new FatalSandboxError(
@@ -11,6 +11,7 @@ import {
MAX_NOTIFICATION_SUBTITLE_CHARS,
MAX_NOTIFICATION_TITLE_CHARS,
notifyViaTerminal,
TerminalNotificationMethod,
} from './terminalNotifications.js';
const writeToStdout = vi.hoisted(() => vi.fn());
@@ -24,38 +25,19 @@ vi.mock('@google/gemini-cli-core', () => ({
}));
describe('terminal notifications', () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
});
vi.stubEnv('TMUX', '');
vi.stubEnv('STY', '');
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('TERM_PROGRAM', '');
vi.stubEnv('TERM', '');
vi.stubEnv('ALACRITTY_WINDOW_ID', '');
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
it('emits notification on non-macOS platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
});
const shown = await notifyViaTerminal(true, {
title: 't',
body: 'b',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalled();
});
it('returns false without writing when disabled', async () => {
@@ -68,8 +50,7 @@ describe('terminal notifications', () => {
expect(writeToStdout).not.toHaveBeenCalled();
});
it('emits OSC 9 notification when supported terminal is detected', async () => {
vi.stubEnv('WT_SESSION', '');
it('emits OSC 9 notification when iTerm2 is detected', async () => {
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
@@ -85,23 +66,57 @@ describe('terminal notifications', () => {
expect(emitted.endsWith('\x07')).toBe(true);
});
it('emits BEL fallback when OSC 9 is not supported', async () => {
vi.stubEnv('TERM_PROGRAM', '');
vi.stubEnv('TERM', '');
it('emits OSC 777 for unknown terminals', async () => {
const shown = await notifyViaTerminal(true, {
title: 'Title',
subtitle: 'Subtitle',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
const emitted = String(writeToStdout.mock.calls[0][0]);
expect(emitted.startsWith('\x1b]777;notify;')).toBe(true);
});
it('uses BEL when Windows Terminal is detected', async () => {
vi.stubEnv('WT_SESSION', '1');
const shown = await notifyViaTerminal(true, {
title: 'Title',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledWith('\x07');
});
it('uses BEL fallback when WT_SESSION is set', async () => {
vi.stubEnv('WT_SESSION', '1');
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
it('uses BEL when Alacritty is detected', async () => {
vi.stubEnv('ALACRITTY_WINDOW_ID', '1');
const shown = await notifyViaTerminal(true, {
title: 'Title',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledWith('\x07');
});
it('uses BEL when Apple Terminal is detected', async () => {
vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal');
const shown = await notifyViaTerminal(true, {
title: 'Title',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledWith('\x07');
});
it('uses BEL when VSCode Terminal is detected', async () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
const shown = await notifyViaTerminal(true, {
title: 'Title',
@@ -127,7 +142,6 @@ describe('terminal notifications', () => {
});
it('strips terminal control sequences and newlines from payload text', async () => {
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
@@ -162,4 +176,124 @@ describe('terminal notifications', () => {
MAX_NOTIFICATION_BODY_CHARS,
);
});
it('emits OSC 9 notification when method is explicitly set to osc9', async () => {
// Explicitly set terminal to something that would normally use BEL
vi.stubEnv('WT_SESSION', '1');
const shown = await notifyViaTerminal(
true,
{
title: 'Explicit OSC 9',
body: 'Body',
},
TerminalNotificationMethod.Osc9,
);
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
const emitted = String(writeToStdout.mock.calls[0][0]);
expect(emitted.startsWith('\x1b]9;')).toBe(true);
expect(emitted.endsWith('\x07')).toBe(true);
expect(emitted).toContain('Explicit OSC 9');
});
it('emits OSC 777 notification when method is explicitly set to osc777', async () => {
// Explicitly set terminal to something that would normally use BEL
vi.stubEnv('WT_SESSION', '1');
const shown = await notifyViaTerminal(
true,
{
title: 'Explicit OSC 777',
body: 'Body',
},
TerminalNotificationMethod.Osc777,
);
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
const emitted = String(writeToStdout.mock.calls[0][0]);
expect(emitted.startsWith('\x1b]777;notify;')).toBe(true);
expect(emitted.endsWith('\x07')).toBe(true);
expect(emitted).toContain('Explicit OSC 777');
});
it('emits BEL notification when method is explicitly set to bell', async () => {
// Explicitly set terminal to something that supports OSC 9
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(
true,
{
title: 'Explicit BEL',
body: 'Body',
},
TerminalNotificationMethod.Bell,
);
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
expect(writeToStdout).toHaveBeenCalledWith('\x07');
});
it('replaces semicolons with colons in OSC 777 to avoid breaking the sequence', async () => {
const shown = await notifyViaTerminal(
true,
{
title: 'Title; with; semicolons',
subtitle: 'Sub;title',
body: 'Body; with; semicolons',
},
TerminalNotificationMethod.Osc777,
);
expect(shown).toBe(true);
const emitted = String(writeToStdout.mock.calls[0][0]);
// Format: \x1b]777;notify;title;body\x07
expect(emitted).toContain('Title: with: semicolons');
expect(emitted).toContain('Sub:title');
expect(emitted).toContain('Body: with: semicolons');
expect(emitted).not.toContain('Title; with; semicolons');
expect(emitted).not.toContain('Body; with; semicolons');
// Extract everything after '\x1b]777;notify;' and before '\x07'
const payload = emitted.slice('\x1b]777;notify;'.length, -1);
// There should be exactly one semicolon separating title and body
const semicolonsCount = (payload.match(/;/g) || []).length;
expect(semicolonsCount).toBe(1);
});
it('wraps OSC sequence in tmux passthrough when TMUX env var is set', async () => {
vi.stubEnv('TMUX', '1');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
title: 'Title',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
const emitted = String(writeToStdout.mock.calls[0][0]);
expect(emitted.startsWith('\x1bPtmux;\x1b\x1b]9;')).toBe(true);
expect(emitted.endsWith('\x1b\\')).toBe(true);
});
it('wraps OSC sequence in GNU screen passthrough when STY env var is set', async () => {
vi.stubEnv('STY', '1');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
title: 'Title',
body: 'Body',
});
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalledTimes(1);
const emitted = String(writeToStdout.mock.calls[0][0]);
expect(emitted.startsWith('\x1bP\x1b]9;')).toBe(true);
expect(emitted.endsWith('\x1b\\')).toBe(true);
});
});
+81 -21
View File
@@ -15,12 +15,8 @@ export const MAX_NOTIFICATION_BODY_CHARS = 180;
const BEL = '\x07';
const OSC9_PREFIX = '\x1b]9;';
const OSC9_SEPARATOR = ' | ';
const MAX_OSC9_MESSAGE_CHARS =
MAX_NOTIFICATION_TITLE_CHARS +
MAX_NOTIFICATION_SUBTITLE_CHARS +
MAX_NOTIFICATION_BODY_CHARS +
OSC9_SEPARATOR.length * 2;
const OSC777_PREFIX = '\x1b]777;notify;';
const OSC_TEXT_SEPARATOR = ' | ';
export interface RunEventNotificationContent {
title: string;
@@ -81,36 +77,100 @@ export function isNotificationsEnabled(settings: LoadedSettings): boolean {
return general?.enableNotifications === true;
}
function buildTerminalNotificationMessage(
content: RunEventNotificationContent,
): string {
const pieces = [content.title, content.subtitle, content.body].filter(
Boolean,
);
const combined = pieces.join(OSC9_SEPARATOR);
return sanitizeForDisplay(combined, MAX_OSC9_MESSAGE_CHARS);
export enum TerminalNotificationMethod {
Auto = 'auto',
Osc9 = 'osc9',
Osc777 = 'osc777',
Bell = 'bell',
}
export function getNotificationMethod(
settings: LoadedSettings,
): TerminalNotificationMethod {
switch (settings.merged.general?.notificationMethod) {
case TerminalNotificationMethod.Osc9:
return TerminalNotificationMethod.Osc9;
case TerminalNotificationMethod.Osc777:
return TerminalNotificationMethod.Osc777;
case TerminalNotificationMethod.Bell:
return TerminalNotificationMethod.Bell;
default:
return TerminalNotificationMethod.Auto;
}
}
function wrapWithPassthrough(sequence: string): string {
const capabilityManager = TerminalCapabilityManager.getInstance();
if (capabilityManager.isTmux()) {
// eslint-disable-next-line no-control-regex
return `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
} else if (capabilityManager.isScreen()) {
return `\x1bP${sequence}\x1b\\`;
}
return sequence;
}
function emitOsc9Notification(content: RunEventNotificationContent): void {
const message = buildTerminalNotificationMessage(content);
if (!TerminalCapabilityManager.getInstance().supportsOsc9Notifications()) {
writeToStdout(BEL);
return;
}
const sanitized = sanitizeNotificationContent(content);
const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter(
Boolean,
);
const combined = pieces.join(OSC_TEXT_SEPARATOR);
writeToStdout(`${OSC9_PREFIX}${message}${BEL}`);
writeToStdout(wrapWithPassthrough(`${OSC9_PREFIX}${combined}${BEL}`));
}
function emitOsc777Notification(content: RunEventNotificationContent): void {
const sanitized = sanitizeNotificationContent(content);
const bodyParts = [sanitized.subtitle, sanitized.body].filter(Boolean);
const body = bodyParts.join(OSC_TEXT_SEPARATOR);
// Replace ';' with ':' to avoid breaking the OSC 777 sequence
const safeTitle = sanitized.title.replace(/;/g, ':');
const safeBody = body.replace(/;/g, ':');
writeToStdout(
wrapWithPassthrough(`${OSC777_PREFIX}${safeTitle};${safeBody}${BEL}`),
);
}
function emitBellNotification(): void {
writeToStdout(BEL);
}
export async function notifyViaTerminal(
notificationsEnabled: boolean,
content: RunEventNotificationContent,
method: TerminalNotificationMethod = TerminalNotificationMethod.Auto,
): Promise<boolean> {
if (!notificationsEnabled) {
return false;
}
try {
emitOsc9Notification(sanitizeNotificationContent(content));
if (method === TerminalNotificationMethod.Osc9) {
emitOsc9Notification(content);
} else if (method === TerminalNotificationMethod.Osc777) {
emitOsc777Notification(content);
} else if (method === TerminalNotificationMethod.Bell) {
emitBellNotification();
} else {
// auto
const capabilityManager = TerminalCapabilityManager.getInstance();
if (capabilityManager.isITerm2()) {
emitOsc9Notification(content);
} else if (
capabilityManager.isAlacritty() ||
capabilityManager.isAppleTerminal() ||
capabilityManager.isVSCodeTerminal() ||
capabilityManager.isWindowsTerminal()
) {
emitBellNotification();
} else {
emitOsc777Notification(content);
}
}
return true;
} catch (error) {
debugLogger.debug('Failed to emit terminal notification:', error);
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SkillExtractionAgent } from './skill-extraction-agent.js';
import {
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
describe('SkillExtractionAgent', () => {
const skillsDir = '/tmp/skills';
const sessionIndex =
'[NEW] Debug login flow (12 user msgs) — /tmp/chats/session-1.json';
const existingSkillsSummary =
'## Workspace Skills (.gemini/skills — do NOT duplicate)\n- **existing-skill**: Existing description';
const agent = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
);
it('should expose expected metadata, model, and tools', () => {
expect(agent.kind).toBe('local');
expect(agent.name).toBe('confucius');
expect(agent.displayName).toBe('Skill Extractor');
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
]),
);
});
it('should default to no skill unless recurrence and durability are proven', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Default to NO SKILL.');
expect(prompt).toContain(
'strong evidence this will recur for future agents in this repo/workflow',
);
expect(prompt).toContain('broader than a single incident');
expect(prompt).toContain('A skill MUST meet ALL of these criteria:');
expect(prompt).toContain(
'Future agents in this repo/workflow are likely to need it',
);
});
it('should explicitly reject one-off incidents and single-session preferences', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Single-session preferences');
expect(prompt).toContain('One-off incidents');
expect(prompt).toContain('Output-style preferences');
expect(prompt).toContain('cannot survive renaming the specific');
});
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
const query = agent.promptConfig.query ?? '';
expect(query).toContain(existingSkillsSummary);
expect(query).toContain(sessionIndex);
expect(query).toContain(
'The summary is a user-intent summary, not a workflow summary.',
);
expect(query).toContain(
'The session summaries describe user intent, not workflow details.',
);
expect(query).toContain(
'Only write a skill if the evidence shows a durable, recurring workflow',
);
expect(query).toContain(
'If recurrence or future reuse is unclear, create no skill and explain why.',
);
});
});
@@ -7,11 +7,13 @@
import { z } from 'zod';
import type { LocalAgentDefinition } from './types.js';
import {
ACTIVATE_SKILL_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -36,7 +38,7 @@ function buildSystemPrompt(skillsDir: string): string {
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
'- reuse proven workflows and verification checklists',
'- avoid known failure modes and landmines',
'- anticipate user preferences without being reminded',
'- capture durable workflow constraints that future agents are likely to encounter again',
'',
'============================================================',
'SAFETY AND HYGIENE (STRICT)',
@@ -59,6 +61,10 @@ function buildSystemPrompt(skillsDir: string): string {
'1. "Is this something a competent agent would NOT already know?" If no, STOP.',
'2. "Does an existing skill (listed below) already cover this?" If yes, STOP.',
'3. "Can I write a concrete, step-by-step procedure?" If no, STOP.',
'4. "Is there strong evidence this will recur for future agents in this repo/workflow?" If no, STOP.',
'5. "Is this broader than a single incident (one bug, one ticket, one branch, one date, one exact error)?" If no, STOP.',
'',
'Default to NO SKILL.',
'',
'Do NOT create skills for:',
'',
@@ -67,6 +73,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- **Pure Q&A**: The user asked "how does X work?" and got an answer. No procedure.',
'- **Brainstorming/design**: Discussion of how to build something, without a validated',
' implementation that produced a reusable procedure.',
'- **Single-session preferences**: User-specific style/output preferences or workflow',
' preferences mentioned only once.',
'- **One-off incidents**: Debugging or incident response tied to a single bug, ticket,',
' branch, date, or exact error string.',
'- **Anything already covered by an existing skill** (global, workspace, builtin, or',
' previously extracted). Check the "Existing Skills" section carefully.',
'',
@@ -74,31 +84,40 @@ function buildSystemPrompt(skillsDir: string): string {
'WHAT COUNTS AS A SKILL',
'============================================================',
'',
'A skill MUST meet BOTH of these criteria:',
'A skill MUST meet ALL of these criteria:',
'',
'1. **Procedural and concrete**: It can be expressed as numbered steps with specific',
' commands, paths, or code patterns. If you can only write vague guidance, it is NOT',
' a skill. "Be careful with X" is advice, not a skill.',
'',
'2. **Non-obvious and project-specific**: A competent agent would NOT already know this.',
' It encodes project-specific knowledge, non-obvious ordering constraints, or',
' hard-won failure shields that cannot be inferred from the codebase alone.',
'2. **Durable and reusable**: Future agents in this repo/workflow are likely to need it',
' again. If it only solved one incident, it is NOT a skill.',
'',
'Confidence tiers (prefer higher tiers):',
'3. **Evidence-backed and project-specific**: It encodes project-specific knowledge,',
' repeated operational constraints, or hard-won failure shields supported by session',
' evidence. Do not assume something is non-obvious just because it sounds detailed.',
'',
'**High confidence** — create the skill:',
'- The same workflow appeared in multiple sessions (cross-session repetition)',
'- A multi-step procedure was validated (tests passed, user confirmed success)',
'Confidence tiers:',
'',
'**Medium confidence** — create the skill if it is clearly project-specific:',
'- A project-specific build/test/deploy/release procedure was established',
'- A non-obvious ordering constraint or prerequisite was discovered',
'- A failure mode was hit and a concrete fix was found and verified',
'**High confidence** — create the skill only when recurrence/durability is clear:',
'- The same workflow appeared in multiple sessions (cross-session repetition), OR it is',
' a stable recurring repo workflow (for example setup/build/test/deploy/release) with a',
' clear future trigger',
'- The workflow was validated (tests passed, user confirmed success, or the same fix',
' worked repeatedly)',
'- The skill can be named without referencing a specific incident, bug, branch, or date',
'',
'**Medium confidence** — usually do NOT create the skill yet:',
'- A project-specific procedure appeared once and seems useful, but recurrence is not yet',
' clear',
'- A verified fix exists, but it is still tied to one incident',
'- A user correction changed the approach once, but durability is uncertain',
'',
'**Low confidence** — do NOT create the skill:',
'- A one-off debugging session with no reusable procedure',
'- Generic workflows any agent could figure out from the codebase',
'- A code review or investigation with no durable takeaway',
'- Output-style preferences that do not materially change procedure',
'',
'Aim for 0-2 skills per run. Quality over quantity.',
'',
@@ -117,8 +136,10 @@ function buildSystemPrompt(skillsDir: string): string {
'',
'What to look for:',
'',
'- User corrections: "No, do it this way" -> preference signal',
'- User corrections that change procedure in a durable way, especially when repeated',
' across sessions',
'- Repeated patterns across sessions: same commands, same file paths, same workflow',
'- Stable recurring repo lifecycle workflows with clear future triggers',
'- Failed attempts followed by successful ones -> failure shield',
'- Multi-step procedures that were validated (tests passed, user confirmed)',
'- User interruptions: "Stop, you need to X first" -> ordering constraint',
@@ -129,45 +150,8 @@ function buildSystemPrompt(skillsDir: string): string {
'- Tool outputs that are just data (file contents, search results)',
'- Speculative plans that were never executed',
"- Temporary context (current branch name, today's date, specific error IDs)",
'',
'============================================================',
'SKILL FORMAT',
'============================================================',
'',
'Each skill is a directory containing a SKILL.md file with YAML frontmatter',
'and optional supporting scripts.',
'',
'Directory structure:',
` ${skillsDir}/<skill-name>/`,
' SKILL.md # Required entrypoint',
' scripts/<tool>.* # Optional helper scripts (Python stdlib-only or shell)',
'',
'SKILL.md structure:',
'',
' ---',
' name: <skill-name>',
' description: <1-2 lines; include concrete triggers in user-like language>',
' ---',
'',
' ## When to Use',
' <Clear trigger conditions and non-goals>',
'',
' ## Procedure',
' <Numbered steps with specific commands, paths, code patterns>',
'',
' ## Pitfalls and Fixes',
' <symptom -> likely cause -> fix; only include observed failures>',
'',
' ## Verification',
' <Concrete success checks>',
'',
'Supporting scripts (optional but recommended when applicable):',
'- Put helper scripts in scripts/ and reference them from SKILL.md',
'- Prefer Python (stdlib only) or small shell scripts',
'- Make scripts safe: no destructive actions, no secrets, deterministic output',
'- Include a usage example in SKILL.md',
'',
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
'- Similar session summaries without matching workflow evidence',
'- One-off artifact names: bug IDs, branch names, timestamps, exact incident strings',
'',
'============================================================',
'UPDATING EXISTING SKILLS (PATCHES)',
@@ -214,7 +198,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- Keep scopes distinct. Avoid overlapping "do-everything" skills.',
'- Every skill MUST have: triggers, procedure, at least one pitfall or verification step.',
'- If you cannot write a reliable procedure (too many unknowns), do NOT create the skill.',
'- Do not create skills for generic advice that any competent agent would already know.',
'- If the candidate is tied to one incident or cannot survive renaming the specific',
' bug/ticket, do NOT create it.',
'- Do not create skills for generic advice, output-style preferences, or ephemeral',
' choices that any competent agent would already know or adapt to on the fly.',
'- Prefer fewer, higher-quality skills. 0-2 skills per run is typical. 3+ is unusual.',
'',
'============================================================',
@@ -223,18 +210,32 @@ function buildSystemPrompt(skillsDir: string): string {
'',
`1. Use list_directory on ${skillsDir} to see existing skills.`,
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
'3. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' suggest workflows that ALSO appear in other sessions (either [NEW] or [old]).',
'4. Apply the minimum signal gate. If no repeated patterns are visible, report that and finish.',
'5. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated.',
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
' skill directories and package_skill.cjs to validate finished skills.',
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
' installation prompts, iteration loops). Use only its format and quality guidance.',
'4. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' hint at workflows that ALSO appear in other sessions (either [NEW] or [old]) or at a',
' stable recurring repo workflow. Remember: summary similarity alone is NOT enough.',
'5. Apply the minimum signal gate. If recurrence or durability is not visible, report that',
' no skill should be created and finish.',
'6. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated. Read at least',
' two sessions unless the candidate is clearly a stable recurring repo lifecycle workflow.',
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
' why it is not generic.',
'8. Write new SKILL.md files or update existing ones in your directory.',
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'8. Write COMPLETE files — never partially update a SKILL.md.',
'9. Write COMPLETE files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
'repeated pattern worth investigating. Most runs should read 0-3 sessions and create 0 skills.',
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
'should read 0-3 sessions and create 0 skills.',
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
].join('\n');
}
@@ -244,8 +245,9 @@ function buildSystemPrompt(skillsDir: string): string {
* writes reusable SKILL.md files to the project memory directory.
*
* This agent is designed to run in the background on session startup.
* It has restricted tool access (file tools only, no shell or user interaction)
* and is prompted to only operate within the skills memory directory.
* It has restricted tool access (file tools, shell, and skill activation no
* user interaction) and is prompted to only operate within the skills memory
* directory.
*/
export const SkillExtractionAgent = (
skillsDir: string,
@@ -279,12 +281,14 @@ export const SkillExtractionAgent = (
},
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
SHELL_TOOL_NAME,
],
},
get promptConfig() {
@@ -301,6 +305,9 @@ export const SkillExtractionAgent = (
'Below is an index of past conversation sessions. Each line shows:',
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
'',
'The summary is a user-intent summary, not a workflow summary.',
'Matching summary text alone is never enough evidence for a reusable skill.',
'',
'[NEW] = not yet processed for skill extraction (focus on these)',
'[old] = previously processed (read only if a [NEW] session hints at a repeated pattern)',
'',
@@ -319,7 +326,7 @@ export const SkillExtractionAgent = (
return {
systemPrompt: buildSystemPrompt(skillsDir),
query: `${initialContext}\n\nAnalyze the session index above. Read sessions that suggest repeated workflows using read_file. Extract reusable skills to ${skillsDir}/.`,
query: `${initialContext}\n\nAnalyze the session index above. The session summaries describe user intent, not workflow details. Read sessions that suggest repeated workflows using read_file. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
};
},
runConfig: {
+89
View File
@@ -1774,6 +1774,95 @@ describe('Server Config (config.ts)', () => {
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
it('updates storage session-scoped directories when the sessionId changes', async () => {
const config = new Config({
...baseParams,
sessionId: 'session-one',
plan: true,
});
await config.initialize();
const tempDir = config.storage.getProjectTempDir();
const oldPlansDir = path.join(tempDir, 'session-one', 'plans');
const oldTrackerService = config.getTrackerService();
config.setSessionId('session-two');
expect(config.getSessionId()).toBe('session-two');
expect(config.storage.getProjectTempPlansDir()).toBe(
path.join(tempDir, 'session-two', 'plans'),
);
expect(config.storage.getProjectTempTrackerDir()).toBe(
path.join(tempDir, 'session-two', 'tracker'),
);
expect(config.getTrackerService()).not.toBe(oldTrackerService);
expect(config.getTrackerService().trackerDir).toBe(
path.join(tempDir, 'session-two', 'tracker'),
);
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
oldPlansDir,
);
});
it('does not throw when changing sessions before the previous plans dir exists', async () => {
const config = new Config({
...baseParams,
sessionId: 'session-one',
plan: true,
});
await config.initialize();
const missingPlansDir = config.storage.getProjectTempPlansDir();
const realpathMock = vi.mocked(fs.realpathSync);
const originalImplementation = realpathMock.getMockImplementation();
try {
realpathMock.mockImplementation((input) => {
const normalizedInput =
typeof input === 'string' || Buffer.isBuffer(input)
? input
: input.toString();
if (normalizedInput === missingPlansDir) {
const error = new Error(
`ENOENT: no such file or directory, ${normalizedInput}`,
);
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (originalImplementation) {
return originalImplementation(input);
}
return normalizedInput;
});
expect(() => config.setSessionId('session-two')).not.toThrow();
} finally {
realpathMock.mockImplementation((input) => {
if (originalImplementation) {
return originalImplementation(input);
}
return typeof input === 'string' || Buffer.isBuffer(input)
? input
: input.toString();
});
}
});
it('clears the approved plan when starting a new session', () => {
const config = new Config({
...baseParams,
sessionId: 'session-one',
});
config.setApprovedPlanPath('/tmp/session-one/plans/approved.md');
expect(() => config.resetNewSessionState('session-two')).not.toThrow();
expect(config.getSessionId()).toBe('session-two');
expect(config.getApprovedPlanPath()).toBeUndefined();
});
});
describe('GemmaModelRouterSettings', () => {
+75
View File
@@ -30,6 +30,8 @@ import { ResourceRegistry } from '../resources/resource-registry.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { LSTool } from '../tools/ls.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadMcpResourceTool } from '../tools/read-mcp-resource.js';
import { ListMcpResourcesTool } from '../tools/list-mcp-resources.js';
import { GrepTool } from '../tools/grep.js';
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
import { GlobTool } from '../tools/glob.js';
@@ -624,6 +626,7 @@ export interface ConfigParameters {
bugCommand?: BugCommandSettings;
model: string;
disableLoopDetection?: boolean;
disableStreaming?: boolean;
maxSessionTurns?: number;
acpMode?: boolean;
listSessions?: boolean;
@@ -726,6 +729,8 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
simulateUser?: boolean;
knowledgeSource?: string;
}
export class Config implements McpContext, AgentLoopContext {
@@ -956,6 +961,9 @@ export class Config implements McpContext, AgentLoopContext {
private lastModeSwitchTime: number = performance.now();
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly simulateUser: boolean;
private readonly knowledgeSource?: string;
private readonly disableStreaming: boolean;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
@@ -1276,6 +1284,9 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.simulateUser = params.simulateUser ?? false;
this.knowledgeSource = params.knowledgeSource;
this.disableStreaming = params.disableStreaming ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -1760,7 +1771,22 @@ export class Config implements McpContext, AgentLoopContext {
}
setSessionId(sessionId: string): void {
const previousPlansDir = this.storage.isInitialized()
? this.storage.getPlansDir()
: undefined;
this._sessionId = sessionId;
this.storage.setSessionId(sessionId);
this.trackerService = undefined;
if (previousPlansDir) {
this.refreshSessionScopedPlansDirectory(previousPlansDir);
}
}
resetNewSessionState(sessionId: string): void {
this.setSessionId(sessionId);
this.approvedPlanPath = undefined;
}
setTerminalBackground(terminalBackground: string | undefined): void {
@@ -2049,6 +2075,37 @@ export class Config implements McpContext, AgentLoopContext {
return getWorkspaceContextOverride() ?? this.workspaceContext;
}
private refreshSessionScopedPlansDirectory(previousPlansDir: string): void {
const nextPlansDir = this.storage.getPlansDir();
if (previousPlansDir === nextPlansDir) {
return;
}
const pathsToRemove = new Set([previousPlansDir]);
try {
pathsToRemove.add(resolveToRealPath(previousPlansDir));
} catch {
// The previous session's plans directory may never have been created.
// In that case there is nothing to resolve or remove beyond the raw path.
}
const currentDirectories = this.workspaceContext
.getDirectories()
.filter((dir) => !pathsToRemove.has(dir));
this.workspaceContext.setDirectories(currentDirectories);
try {
if (fs.existsSync(nextPlansDir)) {
this.workspaceContext.addDirectory(nextPlansDir);
}
} catch {
// Ignore invalid or unreadable plans directories here. This mirrors
// initialization behavior, which only adds the plans directory when it
// already exists and is readable.
}
}
getAgentRegistry(): AgentRegistry {
return this.agentRegistry;
}
@@ -2822,6 +2879,18 @@ export class Config implements McpContext, AgentLoopContext {
return this.usageStatisticsEnabled;
}
getSimulateUser(): boolean {
return this.simulateUser;
}
getDisableStreaming(): boolean {
return this.disableStreaming;
}
getKnowledgeSource(): string | undefined {
return this.knowledgeSource;
}
getAcpMode(): boolean {
return this.acpMode;
}
@@ -3579,6 +3648,12 @@ export class Config implements McpContext, AgentLoopContext {
maybeRegister(WebFetchTool, () =>
registry.registerTool(new WebFetchTool(this, this.messageBus)),
);
maybeRegister(ReadMcpResourceTool, () =>
registry.registerTool(new ReadMcpResourceTool(this, this.messageBus)),
);
maybeRegister(ListMcpResourcesTool, () =>
registry.registerTool(new ListMcpResourcesTool(this, this.messageBus)),
);
maybeRegister(ShellTool, () =>
registry.registerTool(new ShellTool(this, this.messageBus)),
);
@@ -294,7 +294,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
family: 'gemini-3',
isPreview: true,
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
features: { thinking: true, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
+21
View File
@@ -211,6 +211,27 @@ describe('Storage additional helpers', () => {
expect(storageWithSession.getProjectTempTrackerDir()).toBe(expected);
});
it('updates session-scoped directories when the sessionId changes', async () => {
const storageWithSession = new Storage(projectRoot, 'session-one');
ProjectRegistry.prototype.getShortId = vi
.fn()
.mockReturnValue(PROJECT_SLUG);
await storageWithSession.initialize();
const tempDir = storageWithSession.getProjectTempDir();
storageWithSession.setSessionId('session-two');
expect(storageWithSession.getProjectTempPlansDir()).toBe(
path.join(tempDir, 'session-two', 'plans'),
);
expect(storageWithSession.getProjectTempTrackerDir()).toBe(
path.join(tempDir, 'session-two', 'tracker'),
);
expect(storageWithSession.getProjectTempTasksDir()).toBe(
path.join(tempDir, 'session-two', 'tasks'),
);
});
describe('Session and JSON Loading', () => {
beforeEach(async () => {
await storage.initialize();
+9 -1
View File
@@ -28,7 +28,7 @@ export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml';
export class Storage {
private readonly targetDir: string;
private readonly sessionId: string | undefined;
private sessionId: string | undefined;
private projectIdentifier: string | undefined;
private initPromise: Promise<void> | undefined;
private customPlansDir: string | undefined;
@@ -42,6 +42,14 @@ export class Storage {
this.customPlansDir = dir;
}
setSessionId(sessionId: string | undefined): void {
this.sessionId = sessionId;
}
isInitialized(): boolean {
return !!this.projectIdentifier;
}
static getGlobalGeminiDir(): string {
const homeDir = homedir();
if (!homeDir) {
+195 -8
View File
@@ -148,7 +148,7 @@ describe('createContentGenerator', () => {
);
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
@@ -365,7 +365,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -409,7 +409,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -443,7 +443,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -481,7 +481,7 @@ describe('createContentGenerator', () => {
);
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: {
'User-Agent': expect.any(String),
@@ -517,7 +517,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -550,7 +550,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -589,7 +589,7 @@ describe('createContentGenerator', () => {
expect(GoogleGenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
vertexai: undefined,
vertexai: false,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.any(String),
@@ -638,6 +638,193 @@ describe('createContentGenerator', () => {
apiVersion: 'v1alpha',
});
});
it('should pass baseUrl to GoogleGenAI when GOOGLE_GEMINI_BASE_URL is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
vertexai: false,
httpOptions: expect.objectContaining({
baseUrl: 'https://gemini.test.local',
}),
}),
);
});
it('should pass baseUrl to GoogleGenAI when GOOGLE_VERTEX_BASE_URL is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: undefined,
vertexai: true,
httpOptions: expect.objectContaining({
baseUrl: 'https://vertex.test.local',
}),
}),
);
});
it('should prefer GOOGLE_VERTEX_BASE_URL when authType is USE_VERTEX_AI without inferred vertex credentials', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local');
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local');
await createContentGenerator(
{
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: undefined,
vertexai: true,
httpOptions: expect.objectContaining({
baseUrl: 'https://vertex.test.local',
}),
}),
);
});
it('should prefer an explicit baseUrl over GOOGLE_GEMINI_BASE_URL', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://env.test.local');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
undefined,
'https://explicit.test.local',
);
await createContentGenerator(config, mockConfig);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://explicit.test.local',
}),
}),
);
});
it('should allow localhost baseUrl overrides over http', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'http://127.0.0.1:8080',
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'http://127.0.0.1:8080',
}),
}),
);
});
it('should reject invalid custom baseUrl values', async () => {
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'not-a-url',
},
mockConfig,
),
).rejects.toThrow('Invalid custom base URL: not-a-url');
});
it('should reject non-https remote custom baseUrl values', async () => {
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'http://example.com',
},
mockConfig,
),
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
});
});
describe('createContentGeneratorConfig', () => {
+32 -3
View File
@@ -101,6 +101,21 @@ export type ContentGeneratorConfig = {
customHeaders?: Record<string, string>;
};
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
function validateBaseUrl(baseUrl: string): void {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
throw new Error(`Invalid custom base URL: ${baseUrl}`);
}
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
}
}
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
@@ -273,18 +288,32 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
let baseUrl = config.baseUrl;
if (!baseUrl) {
const envBaseUrl =
config.authType === AuthType.USE_VERTEX_AI
? process.env['GOOGLE_VERTEX_BASE_URL']
: process.env['GOOGLE_GEMINI_BASE_URL'];
if (envBaseUrl) {
validateBaseUrl(envBaseUrl);
baseUrl = envBaseUrl;
}
} else {
validateBaseUrl(baseUrl);
}
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
}
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
vertexai: config.vertexai,
vertexai: config.vertexai ?? config.authType === AuthType.USE_VERTEX_AI,
httpOptions,
...(apiVersionEnv && { apiVersion: apiVersionEnv }),
});
+17
View File
@@ -656,6 +656,23 @@ export class GeminiChat {
lastConfig = config;
lastContentsToUse = contentsToUse;
if (this.context.config.getDisableStreaming()) {
const response = await this.context.config
.getContentGenerator()
.generateContent(
{
model: modelToUse,
contents: contentsToUse,
config,
},
prompt_id,
role,
);
return (async function* () {
yield response;
})();
}
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
@@ -47,7 +47,10 @@ toolName = [
# Topic grouping tool is innocuous and used for UI organization.
"update_topic",
# Core agent lifecycle tool
"complete_task"
"complete_task",
# MCP resource tools
"read_mcp_resource",
"list_mcp_resources"
]
decision = "allow"
priority = 50
@@ -1057,6 +1057,25 @@ priority = 100
cliHelpResult.decision,
'cli_help should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
// 7. Verify MCP resource tools are ALLOWED
const listMcpResult = await engine.check(
{ name: 'list_mcp_resources' },
undefined,
);
expect(
listMcpResult.decision,
'list_mcp_resources should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
const readMcpResult = await engine.check(
{ name: 'read_mcp_resource', args: { uri: 'test://resource' } },
undefined,
);
expect(
readMcpResult.decision,
'read_mcp_resource should be ALLOWED in Plan Mode',
).toBe(PolicyDecision.ALLOW);
} finally {
await fs.rm(tempPolicyDir, { recursive: true, force: true });
}
@@ -25,7 +25,6 @@ import {
import {
isStrictlyApproved,
verifySandboxOverrides,
getCommandName,
} from '../utils/commandUtils.js';
import { assertValidPathString } from '../../utils/paths.js';
import {
@@ -40,6 +39,11 @@ import {
import { isErrnoException } from '../utils/fsUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
import {
getCommandRoots,
initializeShellParsers,
stripShellWrapper,
} from '../../utils/shell-utils.js';
let cachedBpfPath: string | undefined;
@@ -218,7 +222,15 @@ export class LinuxSandboxManager implements SandboxManager {
args = ['-c', 'cat > "$1"', '_', ...args];
}
const commandName = await getCommandName({ ...req, command, args });
await initializeShellParsers();
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
const commandName = roots.length > 0 ? roots[0] : join(command);
const isGitCommand = roots.includes('git');
const isApproved = allowOverrides
? await isStrictlyApproved(
{ ...req, command, args },
@@ -253,6 +265,15 @@ export class LinuxSandboxManager implements SandboxManager {
false,
};
// If the workspace is writable and we're running a git command,
// automatically allow write access to the .git directory.
if (workspaceWrite && isGitCommand) {
const gitDir = join(this.options.workspace, '.git');
if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) {
mergedAdditional.fileSystem!.write!.push(gitDir);
}
}
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
req,
mergedAdditional,
@@ -115,14 +115,14 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
workspace,
workspace,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
]);
});
@@ -14,6 +14,7 @@ import {
import { isErrnoException } from '../utils/fsUtils.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { toPathKey } from '../../utils/paths.js';
/**
* Options for building bubblewrap (bwrap) arguments.
@@ -63,58 +64,101 @@ export async function buildBwrapArgs(
'/tmp',
);
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
type MountType =
| '--bind'
| '--ro-bind'
| '--bind-try'
| '--ro-bind-try'
| '--symlink';
bwrapArgs.push(bindFlag, workspace.original, workspace.original);
type Mount =
| {
type: MountType;
src: string;
dest: string;
}
| { type: '--tmpfs-ro'; dest: string };
const mounts: Mount[] = [];
const bindFlag: MountType = workspaceWrite ? '--bind-try' : '--ro-bind-try';
mounts.push({
type: bindFlag,
src: workspace.original,
dest: workspace.original,
});
if (workspace.resolved !== workspace.original) {
bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved);
mounts.push({
type: bindFlag,
src: workspace.resolved,
dest: workspace.resolved,
});
}
for (const includeDir of resolvedPaths.globalIncludes) {
bwrapArgs.push('--ro-bind-try', includeDir, includeDir);
mounts.push({ type: '--ro-bind-try', src: includeDir, dest: includeDir });
}
for (const allowedPath of resolvedPaths.policyAllowed) {
if (fs.existsSync(allowedPath)) {
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
mounts.push({ type: '--bind-try', src: allowedPath, dest: allowedPath });
} else {
// If the path doesn't exist, we still want to allow access to its parent
// to enable creating it.
const parent = dirname(allowedPath);
bwrapArgs.push(
isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
parent,
parent,
);
mounts.push({
type: isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
src: parent,
dest: parent,
});
}
}
for (const p of resolvedPaths.policyRead) {
bwrapArgs.push('--ro-bind-try', p, p);
mounts.push({ type: '--ro-bind-try', src: p, dest: p });
}
// Collect explicit additional write permissions.
for (const p of resolvedPaths.policyWrite) {
bwrapArgs.push('--bind-try', p, p);
mounts.push({ type: '--bind-try', src: p, dest: p });
}
const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey));
for (const file of GOVERNANCE_FILES) {
const filePath = join(workspace.original, file.path);
const realPath = join(workspace.resolved, file.path);
bwrapArgs.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
bwrapArgs.push('--ro-bind', realPath, realPath);
const isExplicitlyWritable =
policyWriteKeys.has(toPathKey(filePath)) ||
policyWriteKeys.has(toPathKey(realPath));
// If the workspace is writable, we allow editing .gitignore and .geminiignore by default.
// .git remains protected unless explicitly requested (e.g. for git commands).
const isImplicitlyWritable = workspaceWrite && file.path !== '.git';
if (!isExplicitlyWritable && !isImplicitlyWritable) {
mounts.push({ type: '--ro-bind', src: filePath, dest: filePath });
if (realPath !== filePath) {
mounts.push({ type: '--ro-bind', src: realPath, dest: realPath });
}
}
}
// Grant read-only access to git worktrees/submodules. We do this last in order to
// ensure that these rules aren't overwritten by broader write policies.
// Grant read-only access to git worktrees/submodules.
if (resolvedPaths.gitWorktree) {
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
if (worktreeGitDir) {
bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir);
if (worktreeGitDir && !policyWriteKeys.has(toPathKey(worktreeGitDir))) {
mounts.push({
type: '--ro-bind-try',
src: worktreeGitDir,
dest: worktreeGitDir,
});
}
if (mainGitDir) {
bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir);
if (mainGitDir && !policyWriteKeys.has(toPathKey(mainGitDir))) {
mounts.push({
type: '--ro-bind-try',
src: mainGitDir,
dest: mainGitDir,
});
}
}
@@ -123,37 +167,23 @@ export async function buildBwrapArgs(
try {
const stat = fs.statSync(p);
if (stat.isDirectory()) {
bwrapArgs.push('--tmpfs', p, '--remount-ro', p);
mounts.push({ type: '--tmpfs-ro', dest: p });
} else {
bwrapArgs.push('--ro-bind', '/dev/null', p);
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
}
} catch (e: unknown) {
if (isErrnoException(e) && e.code === 'ENOENT') {
bwrapArgs.push('--symlink', '/dev/null', p);
mounts.push({ type: '--symlink', src: '/dev/null', dest: p });
} else {
debugLogger.warn(
`Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
);
bwrapArgs.push('--ro-bind', '/dev/null', p);
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
}
}
}
// Mask secret files (.env, .env.*)
const secretArgs = await getSecretFilesArgs(resolvedPaths, maskFilePath);
bwrapArgs.push(...secretArgs);
return bwrapArgs;
}
/**
* Generates bubblewrap arguments to mask secret files.
*/
async function getSecretFilesArgs(
resolvedPaths: ResolvedSandboxPaths,
maskPath: string,
): Promise<string[]> {
const args: string[] = [];
const searchDirs = new Set([
resolvedPaths.workspace.original,
resolvedPaths.workspace.resolved,
@@ -164,9 +194,6 @@ async function getSecretFilesArgs(
for (const dir of searchDirs) {
try {
// Use the native 'find' command for performance and to catch nested secrets.
// We limit depth to 3 to keep it fast while covering common nested structures.
// We use -prune to skip heavy directories efficiently while matching dotfiles.
const findResult = await spawnAsync('find', [
dir,
'-maxdepth',
@@ -203,7 +230,7 @@ async function getSecretFilesArgs(
const files = findResult.stdout.toString().split('\0');
for (const file of files) {
if (file.trim()) {
args.push('--bind', maskPath, file.trim());
mounts.push({ type: '--bind', src: maskFilePath, dest: file.trim() });
}
}
} catch (e) {
@@ -213,5 +240,19 @@ async function getSecretFilesArgs(
);
}
}
return args;
// Sort mounts by destination path length to ensure parents are bound before children.
// This prevents hierarchical masking where a parent mount would hide a child mount.
mounts.sort((a, b) => a.dest.length - b.dest.length);
// Emit final bwrap arguments
for (const m of mounts) {
if (m.type === '--tmpfs-ro') {
bwrapArgs.push('--tmpfs', m.dest, '--remount-ro', m.dest);
} else {
bwrapArgs.push(m.type, m.src, m.dest);
}
}
return bwrapArgs;
}
@@ -22,7 +22,11 @@ import {
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
import { initializeShellParsers } from '../../utils/shell-utils.js';
import {
initializeShellParsers,
getCommandRoots,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import {
isKnownSafeCommand,
isDangerousCommand,
@@ -133,6 +137,22 @@ export class MacOsSandboxManager implements SandboxManager {
false,
};
// If the workspace is writable and we're running a git command,
// automatically allow write access to the .git directory.
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
const isGitCommand = roots.includes('git');
if (workspaceWrite && isGitCommand) {
const gitDir = path.join(this.options.workspace, '.git');
if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) {
mergedAdditional.fileSystem!.write!.push(gitDir);
}
}
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
req,
mergedAdditional,
@@ -16,7 +16,7 @@ import {
SECRET_FILES,
type ResolvedSandboxPaths,
} from '../../services/sandboxManager.js';
import { resolveToRealPath } from '../../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
/**
* Options for building macOS Seatbelt profile.
@@ -37,6 +37,47 @@ export function escapeSchemeString(str: string): string {
return str.replace(/[\\"]/g, '\\$&');
}
/**
* Checks if a path is explicitly allowed by additional write permissions.
*/
function isPathExplicitlyAllowed(
filePath: string,
realFilePath: string,
policyWrite: string[],
): boolean {
return policyWrite.some(
(p) =>
p === filePath ||
p === realFilePath ||
isSubpath(p, filePath) ||
isSubpath(p, realFilePath),
);
}
function denyUnlessExplicitlyAllowed(
targetPath: string,
ruleType: 'literal' | 'subpath',
policyWrite: string[],
implicitlyAllowed: boolean = false,
): string {
if (implicitlyAllowed) {
return '';
}
const realPath = resolveToRealPath(targetPath);
if (isPathExplicitlyAllowed(targetPath, realPath, policyWrite)) {
return ''; // Skip if explicitly allowed
}
let rules = `(deny file-write* (${ruleType} "${escapeSchemeString(targetPath)}"))\n`;
if (realPath !== targetPath) {
rules += `(deny file-write* (${ruleType} "${escapeSchemeString(realPath)}"))\n`;
}
return rules;
}
/**
* Builds a complete macOS Seatbelt profile string using a strict allowlist.
* It embeds paths directly into the profile, properly escaped for Scheme.
@@ -108,38 +149,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`;
}
// Handle granular additional read permissions
for (let i = 0; i < resolvedPaths.policyRead.length; i++) {
const resolved = resolvedPaths.policyRead[i];
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`;
} else {
profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`;
}
}
// Handle granular additional write permissions
for (let i = 0; i < resolvedPaths.policyWrite.length; i++) {
const resolved = resolvedPaths.policyWrite[i];
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`;
} else {
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`;
}
}
// Add explicit deny rules for governance files in the workspace.
// These are added after the workspace allow rule to ensure they take precedence
// (Seatbelt evaluates rules in order, later rules win for same path).
@@ -161,13 +170,12 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
// Ignore errors, use default guess
}
const ruleType = isDirectory ? 'subpath' : 'literal';
profile += `(deny file-write* (${ruleType} "${escapeSchemeString(governanceFile)}"))\n`;
if (realGovernanceFile !== governanceFile) {
profile += `(deny file-write* (${ruleType} "${escapeSchemeString(realGovernanceFile)}"))\n`;
}
profile += denyUnlessExplicitlyAllowed(
governanceFile,
isDirectory ? 'subpath' : 'literal',
resolvedPaths.policyWrite,
workspaceWrite && GOVERNANCE_FILES[i].path !== '.git',
);
}
// Grant read-only access to git worktrees/submodules. We do this last in order to
@@ -175,10 +183,18 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
if (resolvedPaths.gitWorktree) {
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
if (worktreeGitDir) {
profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`;
profile += denyUnlessExplicitlyAllowed(
worktreeGitDir,
'subpath',
resolvedPaths.policyWrite,
);
}
if (mainGitDir) {
profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`;
profile += denyUnlessExplicitlyAllowed(
mainGitDir,
'subpath',
resolvedPaths.policyWrite,
);
}
}
@@ -211,6 +227,38 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
}
}
// Handle granular additional read permissions
for (let i = 0; i < resolvedPaths.policyRead.length; i++) {
const resolved = resolvedPaths.policyRead[i];
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`;
} else {
profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`;
}
}
// Handle granular additional write permissions
for (let i = 0; i < resolvedPaths.policyWrite.length; i++) {
const resolved = resolvedPaths.policyWrite[i];
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`;
} else {
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`;
}
}
// Handle forbiddenPaths
const forbiddenPaths = resolvedPaths.forbidden;
for (let i = 0; i < forbiddenPaths.length; i++) {
@@ -25,7 +25,13 @@ import {
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
import {
spawnAsync,
getCommandName,
initializeShellParsers,
getCommandRoots,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import {
isKnownSafeCommand,
isDangerousCommand,
@@ -261,6 +267,14 @@ export class WindowsSandboxManager implements SandboxManager {
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
await initializeShellParsers();
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
const isGitCommand = roots.includes('git');
const resolvedPaths = await resolveSandboxPaths(
this.options,
req,
@@ -348,6 +362,13 @@ export class WindowsSandboxManager implements SandboxManager {
if (workspaceWrite) {
addWritableRoot(resolvedPaths.workspace.resolved);
// If the workspace is writable and we're running a git command,
// automatically allow write access to the .git directory.
if (isGitCommand) {
const gitDir = path.join(resolvedPaths.workspace.resolved, '.git');
addWritableRoot(gitDir);
}
}
// B. Globally included directories
@@ -865,6 +865,336 @@ describe('SandboxManager Integration', () => {
});
});
describe('Governance Files', () => {
it('blocks write access to governance files in the workspace', async () => {
const tempWorkspace = createTempDir('workspace-');
const gitDir = path.join(tempWorkspace, '.git');
fs.mkdirSync(gitDir);
const testFile = path.join(gitDir, 'config');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: tempWorkspace },
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
expect(fs.existsSync(testFile)).toBe(false);
});
it('allows write access to governance files when explicitly requested via additionalPermissions', async () => {
const tempWorkspace = createTempDir('workspace-');
const gitDir = path.join(tempWorkspace, '.git');
fs.mkdirSync(gitDir);
const testFile = path.join(gitDir, 'config');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: tempWorkspace },
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
additionalPermissions: { fileSystem: { write: [gitDir] } },
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(testFile)).toBe(true);
});
});
describe('Git Worktree Support', () => {
it('allows access to git common directory in a worktree', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
fs.writeFileSync(
path.join(mainGitDir, 'config'),
'[core]\n\trepositoryformatversion = 0\n',
);
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
// Create the .git file in the worktree directory pointing to the worktree git dir
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
// Create the backlink from worktree git dir to the worktree's .git file
const backlinkPath = path.join(worktreeGitDir, 'gitdir');
fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git'));
// Create a file in the worktree git dir that we want to access
const secretFile = path.join(worktreeGitDir, 'secret.txt');
fs.writeFileSync(secretFile, 'git-secret');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: worktreeDir },
);
const { command, args } = Platform.cat(secretFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(result.stdout.trim()).toBe('git-secret');
});
it('blocks write access to git common directory in a worktree', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
fs.writeFileSync(
path.join(worktreeGitDir, 'gitdir'),
path.join(worktreeDir, '.git'),
);
const targetFile = path.join(worktreeGitDir, 'secret.txt');
const osManager = createSandboxManager(
{ enabled: true },
// Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only
{ workspace: worktreeDir, modeConfig: { yolo: true } },
);
const { command, args } = Platform.touch(targetFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
expect(fs.existsSync(targetFile)).toBe(false);
});
it('blocks write access to git common directory in a worktree when not explicitly requested via additionalPermissions', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
fs.writeFileSync(
path.join(worktreeGitDir, 'gitdir'),
path.join(worktreeDir, '.git'),
);
const targetFile = path.join(worktreeGitDir, 'secret.txt');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: worktreeDir },
);
const { command, args } = Platform.touch(targetFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
expect(fs.existsSync(targetFile)).toBe(false);
});
it('allows write access to git common directory in a worktree when explicitly requested via additionalPermissions', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
fs.writeFileSync(
path.join(worktreeGitDir, 'gitdir'),
path.join(worktreeDir, '.git'),
);
const targetFile = path.join(worktreeGitDir, 'secret.txt');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: worktreeDir },
);
const { command, args } = Platform.touch(targetFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
policy: {
additionalPermissions: { fileSystem: { write: [worktreeGitDir] } },
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(targetFile)).toBe(true);
});
it('allows write access to external git directory in a non-worktree environment when explicitly requested via additionalPermissions', async () => {
const externalGitDir = createTempDir('external-git-');
const workspaceDir = createTempDir('workspace-');
fs.mkdirSync(externalGitDir, { recursive: true });
fs.writeFileSync(
path.join(workspaceDir, '.git'),
`gitdir: ${externalGitDir}\n`,
);
const targetFile = path.join(externalGitDir, 'secret.txt');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: workspaceDir },
);
const { command, args } = Platform.touch(targetFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: workspaceDir,
env: process.env,
policy: {
additionalPermissions: { fileSystem: { write: [externalGitDir] } },
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(targetFile)).toBe(true);
});
});
describe('Git and Governance Write Access', () => {
it('allows write access to .gitignore when workspace is writable', async () => {
const testFile = path.join(workspace, '.gitignore');
fs.writeFileSync(testFile, 'initial');
const editManager = createSandboxManager(
{ enabled: true },
{ workspace, modeConfig: { readonly: false, allowOverrides: true } },
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await editManager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(testFile)).toBe(true);
});
it('automatically allows write access to .git when running git command and workspace is writable', async () => {
const gitDir = path.join(workspace, '.git');
if (!fs.existsSync(gitDir)) fs.mkdirSync(gitDir);
const lockFile = path.join(gitDir, 'index.lock');
const editManager = createSandboxManager(
{ enabled: true },
{ workspace, modeConfig: { readonly: false, allowOverrides: true } },
);
// We use a command that looks like git to trigger the special handling.
// LinuxSandboxManager identifies the command root from the shell wrapper.
const { command: nodePath, args: nodeArgs } = Platform.touch(lockFile);
const commandString = Platform.isWindows
? `git --version > NUL && "${nodePath.replace(/\\/g, '/')}" ${nodeArgs
.map((a) => `'${a.replace(/\\/g, '/')}'`)
.join(' ')}`
: `git --version > /dev/null; "${nodePath}" ${nodeArgs
.map((a) => (a.includes(' ') || a.includes('(') ? `'${a}'` : a))
.join(' ')}`;
const sandboxed = await editManager.prepareCommand({
command: 'sh',
args: ['-c', commandString],
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(lockFile)).toBe(true);
});
});
describe('Network Security', () => {
describe('Network Access', () => {
let server: http.Server;
@@ -409,6 +409,23 @@ export async function resolveSandboxPaths(
? { gitWorktree: { worktreeGitDir, mainGitDir } }
: undefined;
if (worktreeGitDir) {
const gitIdentities = new Set(
[
path.join(options.workspace, '.git'),
path.join(resolvedWorkspace, '.git'),
].map(toPathKey),
);
if (policyRead.some((p) => gitIdentities.has(toPathKey(p)))) {
policyRead.push(worktreeGitDir);
if (mainGitDir) policyRead.push(mainGitDir);
}
if (policyWrite.some((p) => gitIdentities.has(toPathKey(p)))) {
policyWrite.push(worktreeGitDir);
if (mainGitDir) policyWrite.push(mainGitDir);
}
}
/**
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
*/

Some files were not shown because too many files have changed in this diff Show More