Compare commits

...

25 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
105 changed files with 4384 additions and 743 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".
+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
+7 -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`
+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.",
+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: {
+5 -4
View File
@@ -5,10 +5,7 @@
*/
import { describe, expect } from 'vitest';
import {
TRACKER_CREATE_TASK_TOOL_NAME,
TRACKER_UPDATE_TASK_TOOL_NAME,
} from '@google/gemini-cli-core';
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', () => {
@@ -22,6 +19,8 @@ describe('subtask delegation eval test cases', () => {
* 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: {
@@ -90,6 +89,8 @@ You are the doc expert. Document the provided implementation clearly.`,
* 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: {
+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 │
@@ -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);
@@ -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,
]);
}
+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);
+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) {
+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).
*/
+1
View File
@@ -16,5 +16,6 @@ export enum LlmRole {
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
UTILITY_SIMULATOR = 'utility_simulator',
UTILITY_STATE_SNAPSHOT_PROCESSOR = 'utility_state_snapshot_processor',
}
@@ -137,3 +137,7 @@ export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
// -- complete_task --
export const COMPLETE_TASK_TOOL_NAME = 'complete_task';
export const COMPLETE_TASK_DISPLAY_NAME = 'Complete Task';
// -- MCP Resources --
export const READ_MCP_RESOURCE_TOOL_NAME = 'read_mcp_resource';
export const LIST_MCP_RESOURCES_TOOL_NAME = 'list_mcp_resources';
@@ -43,6 +43,8 @@ export {
UPDATE_TOPIC_DISPLAY_NAME,
COMPLETE_TASK_TOOL_NAME,
COMPLETE_TASK_DISPLAY_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -280,3 +282,17 @@ export function getActivateSkillDefinition(
overrides: (modelId) => getToolSet(modelId).activate_skill(skillNames),
};
}
export const READ_MCP_RESOURCE_DEFINITION: ToolDefinition = {
get base() {
return DEFAULT_LEGACY_SET.read_mcp_resource;
},
overrides: (modelId) => getToolSet(modelId).read_mcp_resource,
};
export const LIST_MCP_RESOURCES_DEFINITION: ToolDefinition = {
get base() {
return DEFAULT_LEGACY_SET.list_mcp_resources;
},
overrides: (modelId) => getToolSet(modelId).list_mcp_resources,
};
@@ -25,6 +25,8 @@ import {
GET_INTERNAL_DOCS_TOOL_NAME,
ASK_USER_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -756,4 +758,37 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
read_mcp_resource: {
name: READ_MCP_RESOURCE_TOOL_NAME,
description:
'Reads the content of a specified Model Context Protocol (MCP) resource.',
parametersJsonSchema: {
type: 'object',
properties: {
uri: {
description: 'The URI of the MCP resource to read.',
type: 'string',
},
},
required: ['uri'],
},
},
list_mcp_resources: {
name: LIST_MCP_RESOURCES_TOOL_NAME,
description:
'Lists all available resources exposed by connected MCP servers.',
parametersJsonSchema: {
type: 'object',
properties: {
serverName: {
description:
'Optional filter to list resources from a specific server.',
type: 'string',
},
},
required: [],
},
},
};
@@ -25,6 +25,8 @@ import {
GET_INTERNAL_DOCS_TOOL_NAME,
ASK_USER_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -733,4 +735,37 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
update_topic: getUpdateTopicDeclaration(),
read_mcp_resource: {
name: READ_MCP_RESOURCE_TOOL_NAME,
description:
'Reads the content of a specified Model Context Protocol (MCP) resource.',
parametersJsonSchema: {
type: 'object',
properties: {
uri: {
description: 'The URI of the MCP resource to read.',
type: 'string',
},
},
required: ['uri'],
},
},
list_mcp_resources: {
name: LIST_MCP_RESOURCES_TOOL_NAME,
description:
'Lists all available resources exposed by connected MCP servers.',
parametersJsonSchema: {
type: 'object',
properties: {
serverName: {
description:
'Optional filter to list resources from a specific server.',
type: 'string',
},
},
required: [],
},
},
};
@@ -50,5 +50,7 @@ export interface CoreToolSet {
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
read_mcp_resource: FunctionDeclaration;
list_mcp_resources: FunctionDeclaration;
update_topic?: FunctionDeclaration;
}
+6 -2
View File
@@ -102,8 +102,12 @@ class GetInternalDocsInvocation extends BaseToolInvocation<
const docsRoot = await getDocsRoot();
if (!this.params.path) {
// List all .md files recursively
const files = await glob('**/*.md', { cwd: docsRoot, posix: true });
// List all .md and .mdx files recursively
const files = await glob('**/*.{md,mdx}', {
cwd: docsRoot,
posix: true,
});
files.sort();
const fileList = files.map((f) => `- ${f}`).join('\n');
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { ListMcpResourcesTool } from './list-mcp-resources.js';
import { ToolErrorType } from './tool-error.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
describe('ListMcpResourcesTool', () => {
let tool: ListMcpResourcesTool;
let mockContext: {
config: {
getMcpClientManager: Mock;
};
};
let mockMcpManager: {
getAllResources: Mock;
};
const abortSignal = new AbortController().signal;
beforeEach(() => {
mockMcpManager = {
getAllResources: vi.fn(),
};
mockContext = {
config: {
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
},
};
tool = new ListMcpResourcesTool(
mockContext as unknown as AgentLoopContext,
createMockMessageBus(),
);
});
it('should successfully list all resources', async () => {
const resources = [
{
uri: 'protocol://r1',
serverName: 'server1',
name: 'R1',
description: 'D1',
},
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
];
mockMcpManager.getAllResources.mockReturnValue(resources);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({});
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(mockMcpManager.getAllResources).toHaveBeenCalled();
expect(result.llmContent).toContain('Available MCP Resources:');
expect(result.llmContent).toContain('protocol://r1');
expect(result.llmContent).toContain('protocol://r2');
expect(result.returnDisplay).toBe('Listed 2 resources.');
});
it('should filter by server name', async () => {
const resources = [
{ uri: 'protocol://r1', serverName: 'server1', name: 'R1' },
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
];
mockMcpManager.getAllResources.mockReturnValue(resources);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ serverName: 'server1' });
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(result.llmContent).toContain('protocol://r1');
expect(result.llmContent).not.toContain('protocol://r2');
expect(result.returnDisplay).toBe('Listed 1 resources.');
});
it('should return message if no resources found', async () => {
mockMcpManager.getAllResources.mockReturnValue([]);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({});
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(result.llmContent).toBe('No MCP resources found.');
expect(result.returnDisplay).toBe('No MCP resources found.');
});
it('should return message if no resources found for server', async () => {
mockMcpManager.getAllResources.mockReturnValue([]);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ serverName: 'nonexistent' });
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(result.llmContent).toBe(
'No resources found for server: nonexistent',
);
expect(result.returnDisplay).toBe(
'No resources found for server: nonexistent',
);
});
it('should return error if MCP Client Manager not available', async () => {
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({});
const result = (await invocation.execute({ abortSignal })) as {
error: { type: string; message: string };
};
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
expect(result.error?.message).toContain('MCP Client Manager not available');
});
});
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
type ExecuteOptions,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { LIST_MCP_RESOURCES_TOOL_NAME } from './tool-names.js';
import { LIST_MCP_RESOURCES_DEFINITION } from './definitions/coreTools.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { ToolErrorType } from './tool-error.js';
export interface ListMcpResourcesParams {
serverName?: string;
}
export class ListMcpResourcesTool extends BaseDeclarativeTool<
ListMcpResourcesParams,
ToolResult
> {
static readonly Name = LIST_MCP_RESOURCES_TOOL_NAME;
constructor(
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
super(
ListMcpResourcesTool.Name,
'List MCP Resources',
LIST_MCP_RESOURCES_DEFINITION.base.description!,
Kind.Search,
LIST_MCP_RESOURCES_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
);
}
protected createInvocation(
params: ListMcpResourcesParams,
): ListMcpResourcesToolInvocation {
return new ListMcpResourcesToolInvocation(
this.context,
params,
this.messageBus,
);
}
}
class ListMcpResourcesToolInvocation extends BaseToolInvocation<
ListMcpResourcesParams,
ToolResult
> {
constructor(
private readonly context: AgentLoopContext,
params: ListMcpResourcesParams,
messageBus: MessageBus,
) {
super(params, messageBus, ListMcpResourcesTool.Name, 'List MCP Resources');
}
getDescription(): string {
return 'List MCP resources';
}
async execute({
abortSignal: _abortSignal,
}: ExecuteOptions): Promise<ToolResult> {
const mcpManager = this.context.config.getMcpClientManager();
if (!mcpManager) {
return {
llmContent: 'Error: MCP Client Manager not available.',
returnDisplay: 'Error: MCP Client Manager not available.',
error: {
message: 'MCP Client Manager not available.',
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
let resources = mcpManager.getAllResources();
const serverName = this.params.serverName;
if (serverName) {
resources = resources.filter((r) => r.serverName === serverName);
}
if (resources.length === 0) {
const msg = serverName
? `No resources found for server: ${serverName}`
: 'No MCP resources found.';
return {
llmContent: msg,
returnDisplay: msg,
};
}
// Format the list
let content = 'Available MCP Resources:\n';
for (const resource of resources) {
content += `- ${resource.serverName}:${resource.uri}`;
if (resource.name) {
content += ` | ${resource.name}`;
}
if (resource.description) {
content += ` | ${resource.description}`;
}
content += '\n';
}
return {
llmContent: content,
returnDisplay: `Listed ${resources.length} resources.`,
};
}
}
@@ -821,4 +821,64 @@ describe('McpClientManager', () => {
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(2); // Now the actual error
});
});
describe('findResourceByUri', () => {
it('should find resource by exact URI match', () => {
const mockResource = { uri: 'test://resource1', name: 'Resource 1' };
const mockResourceRegistry = {
getAllResources: vi.fn().mockReturnValue([mockResource]),
findResourceByUri: vi.fn(),
};
mockConfig.getResourceRegistry.mockReturnValue(
mockResourceRegistry as unknown as ResourceRegistry,
);
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
const result = manager.findResourceByUri('test://resource1');
expect(result).toBe(mockResource);
});
it('should try ResourceRegistry.findResourceByUri first', () => {
const mockResourceQualified = {
uri: 'test://resource1',
name: 'Resource 1 Qualified',
};
const mockResourceDirect = {
uri: 'test-server:test://resource1',
name: 'Resource 1 Direct',
};
const mockResourceRegistry = {
getAllResources: vi.fn().mockReturnValue([mockResourceDirect]),
findResourceByUri: vi.fn().mockReturnValue(mockResourceQualified),
};
mockConfig.getResourceRegistry.mockReturnValue(
mockResourceRegistry as unknown as ResourceRegistry,
);
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
const result = manager.findResourceByUri('test-server:test://resource1');
expect(result).toBe(mockResourceQualified);
expect(mockResourceRegistry.findResourceByUri).toHaveBeenCalledWith(
'test-server:test://resource1',
);
expect(mockResourceRegistry.getAllResources).not.toHaveBeenCalled();
});
it('should return undefined if both fail', () => {
const mockResourceRegistry = {
getAllResources: vi.fn().mockReturnValue([]),
findResourceByUri: vi.fn().mockReturnValue(undefined),
};
mockConfig.getResourceRegistry.mockReturnValue(
mockResourceRegistry as unknown as ResourceRegistry,
);
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
const result = manager.findResourceByUri('non-existent');
expect(result).toBeUndefined();
});
});
});
+30 -2
View File
@@ -24,7 +24,10 @@ import { debugLogger } from '../utils/debugLogger.js';
import { createHash } from 'node:crypto';
import { stableStringify } from '../policy/stable-stringify.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import type { ResourceRegistry } from '../resources/resource-registry.js';
import type {
ResourceRegistry,
MCPResource,
} from '../resources/resource-registry.js';
/**
* Manages the lifecycle of multiple MCP clients, including local child processes.
@@ -161,7 +164,32 @@ export class McpClientManager {
}
getClient(serverName: string): McpClient | undefined {
return this.clients.get(serverName);
for (const client of this.clients.values()) {
if (client.getServerName() === serverName) {
return client;
}
}
return undefined;
}
findResourceByUri(uri: string): MCPResource | undefined {
if (!this.mainResourceRegistry) return undefined;
// Try serverName:uri format first
const qualifiedMatch = this.mainResourceRegistry.findResourceByUri(uri);
if (qualifiedMatch) {
return qualifiedMatch;
}
// Try direct URI match
return this.mainResourceRegistry
.getAllResources()
.find((r) => r.uri === uri);
}
getAllResources(): MCPResource[] {
if (!this.mainResourceRegistry) return [];
return this.mainResourceRegistry.getAllResources();
}
removeRegistries(registries: {
@@ -0,0 +1,194 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { ReadMcpResourceTool } from './read-mcp-resource.js';
import { ToolErrorType } from './tool-error.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
describe('ReadMcpResourceTool', () => {
let tool: ReadMcpResourceTool;
let mockContext: {
config: {
getMcpClientManager: Mock;
};
};
let mockMcpManager: {
findResourceByUri: Mock;
getClient: Mock;
};
const abortSignal = new AbortController().signal;
beforeEach(() => {
mockMcpManager = {
findResourceByUri: vi.fn(),
getClient: vi.fn(),
};
mockContext = {
config: {
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
},
};
tool = new ReadMcpResourceTool(
mockContext as unknown as AgentLoopContext,
createMockMessageBus(),
);
});
it('should successfully read a resource', async () => {
const uri = 'protocol://resource';
const serverName = 'test-server';
const resourceName = 'Test Resource';
const resourceContent = 'Resource Content';
mockMcpManager.findResourceByUri.mockReturnValue({
uri,
serverName,
name: resourceName,
});
const mockClient = {
readResource: vi.fn().mockResolvedValue({
contents: [{ text: resourceContent }],
}),
};
mockMcpManager.getClient.mockReturnValue(mockClient);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
getDescription: () => string;
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ uri });
// Verify description
expect(invocation.getDescription()).toBe(
`Read MCP resource "${resourceName}" from server "${serverName}"`,
);
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(uri);
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
expect(mockClient.readResource).toHaveBeenCalledWith(uri);
expect(result).toEqual({
llmContent: resourceContent + '\n',
returnDisplay: `Successfully read resource "${resourceName}" from server "${serverName}"`,
});
});
it('should pass raw URI to client when using qualified URI', async () => {
const qualifiedUri = 'test-server:protocol://resource';
const rawUri = 'protocol://resource';
const serverName = 'test-server';
const resourceName = 'Test Resource';
const resourceContent = 'Resource Content';
mockMcpManager.findResourceByUri.mockReturnValue({
uri: rawUri,
serverName,
name: resourceName,
});
const mockClient = {
readResource: vi.fn().mockResolvedValue({
contents: [{ text: resourceContent }],
}),
};
mockMcpManager.getClient.mockReturnValue(mockClient);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ uri: qualifiedUri });
const result = (await invocation.execute({ abortSignal })) as {
llmContent: string;
returnDisplay: string;
};
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(qualifiedUri);
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
expect(mockClient.readResource).toHaveBeenCalledWith(rawUri);
expect(result.llmContent).toBe(resourceContent + '\n');
});
it('should return error if MCP Client Manager not available', async () => {
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ uri: 'uri' });
const result = (await invocation.execute({ abortSignal })) as {
error: { type: string; message: string };
};
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
expect(result.error?.message).toContain('MCP Client Manager not available');
});
it('should return error if resource not found', async () => {
mockMcpManager.findResourceByUri.mockReturnValue(undefined);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ uri: 'uri' });
const result = (await invocation.execute({ abortSignal })) as {
error: { type: string; message: string };
};
expect(result.error?.type).toBe(ToolErrorType.MCP_RESOURCE_NOT_FOUND);
expect(result.error?.message).toContain('Resource not found');
});
it('should return error if reading fails', async () => {
const uri = 'protocol://resource';
const serverName = 'test-server';
mockMcpManager.findResourceByUri.mockReturnValue({
uri,
serverName,
});
const mockClient = {
readResource: vi.fn().mockRejectedValue(new Error('Failed to read')),
};
mockMcpManager.getClient.mockReturnValue(mockClient);
const invocation = (
tool as unknown as {
createInvocation: (params: Record<string, unknown>) => {
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
};
}
).createInvocation({ uri });
const result = (await invocation.execute({ abortSignal })) as {
error: { type: string; message: string };
};
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
expect(result.error?.message).toContain('Failed to read resource');
});
});
@@ -0,0 +1,169 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
type ExecuteOptions,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { READ_MCP_RESOURCE_TOOL_NAME } from './tool-names.js';
import { READ_MCP_RESOURCE_DEFINITION } from './definitions/coreTools.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { ToolErrorType } from './tool-error.js';
import type { MCPResource } from '../resources/resource-registry.js';
export interface ReadMcpResourceParams {
uri: string;
}
export class ReadMcpResourceTool extends BaseDeclarativeTool<
ReadMcpResourceParams,
ToolResult
> {
static readonly Name = READ_MCP_RESOURCE_TOOL_NAME;
constructor(
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
super(
ReadMcpResourceTool.Name,
'Read MCP Resource',
READ_MCP_RESOURCE_DEFINITION.base.description!,
Kind.Read,
READ_MCP_RESOURCE_DEFINITION.base.parametersJsonSchema,
messageBus,
true,
false,
);
}
protected createInvocation(
params: ReadMcpResourceParams,
): ReadMcpResourceToolInvocation {
return new ReadMcpResourceToolInvocation(
this.context,
params,
this.messageBus,
);
}
}
class ReadMcpResourceToolInvocation extends BaseToolInvocation<
ReadMcpResourceParams,
ToolResult
> {
private resource: MCPResource | undefined;
constructor(
private readonly context: AgentLoopContext,
params: ReadMcpResourceParams,
messageBus: MessageBus,
) {
super(params, messageBus, ReadMcpResourceTool.Name, 'Read MCP Resource');
const mcpManager = this.context.config.getMcpClientManager();
this.resource = mcpManager?.findResourceByUri(params.uri);
}
getDescription(): string {
if (this.resource) {
return `Read MCP resource "${this.resource.name}" from server "${this.resource.serverName}"`;
}
return `Read MCP resource: ${this.params.uri}`;
}
async execute({
abortSignal: _abortSignal,
}: ExecuteOptions): Promise<ToolResult> {
const mcpManager = this.context.config.getMcpClientManager();
if (!mcpManager) {
return {
llmContent: 'Error: MCP Client Manager not available.',
returnDisplay: 'Error: MCP Client Manager not available.',
error: {
message: 'MCP Client Manager not available.',
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
const uri = this.params.uri;
if (!uri) {
return {
llmContent: 'Error: No URI provided.',
returnDisplay: 'Error: No URI provided.',
error: {
message: 'No URI provided.',
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
}
const resource = mcpManager.findResourceByUri(uri);
if (!resource) {
const errorMessage = `Resource not found for URI: ${uri}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_RESOURCE_NOT_FOUND,
},
};
}
const client = mcpManager.getClient(resource.serverName);
if (!client) {
const errorMessage = `MCP Client not found for server: ${resource.serverName}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
try {
const result = await client.readResource(resource.uri);
// The result should contain contents.
// Let's assume it returns a string or an object with contents.
// According to MCP spec, it returns { contents: [...] }.
// We should format it nicely.
let contentText = '';
if (result && result.contents) {
for (const content of result.contents) {
if ('text' in content && content.text) {
contentText += content.text + '\n';
} else if ('blob' in content && content.blob) {
contentText += `[Binary Data (${content.mimeType})]` + '\n';
}
}
}
return {
llmContent: contentText || 'No content returned from resource.',
returnDisplay: this.resource
? `Successfully read resource "${this.resource.name}" from server "${this.resource.serverName}"`
: `Successfully read resource: ${uri}`,
};
} catch (e) {
const errorMessage = `Failed to read resource: ${e instanceof Error ? e.message : String(e)}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_TOOL_ERROR,
},
};
}
}
}
+1
View File
@@ -55,6 +55,7 @@ export enum ToolErrorType {
// MCP-specific Errors
MCP_TOOL_ERROR = 'mcp_tool_error',
MCP_RESOURCE_NOT_FOUND = 'mcp_resource_not_found',
// Memory-specific Errors
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
+8
View File
@@ -79,6 +79,8 @@ import {
UPDATE_TOPIC_DISPLAY_NAME,
COMPLETE_TASK_TOOL_NAME,
COMPLETE_TASK_DISPLAY_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
@@ -106,6 +108,8 @@ export {
UPDATE_TOPIC_DISPLAY_NAME,
COMPLETE_TASK_TOOL_NAME,
COMPLETE_TASK_DISPLAY_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -272,6 +276,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [
UPDATE_TOPIC_TOOL_NAME,
COMPLETE_TASK_TOOL_NAME,
AGENT_TOOL_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
] as const;
/**
@@ -291,6 +297,8 @@ export const PLAN_MODE_TOOLS = [
UPDATE_TOPIC_TOOL_NAME,
'codebase_investigator',
'cli_help',
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
] as const;
/**
+12
View File
@@ -34,6 +34,8 @@ import {
UPDATE_TOPIC_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
READ_MCP_RESOURCE_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
} from './tool-names.js';
type ToolParams = Record<string, unknown>;
@@ -602,6 +604,16 @@ export class ToolRegistry {
}
}
if (
tool.name === READ_MCP_RESOURCE_TOOL_NAME ||
tool.name === LIST_MCP_RESOURCES_TOOL_NAME
) {
const mcpManager = this.config.getMcpClientManager();
if (!mcpManager || mcpManager.getAllResources().length === 0) {
return false;
}
}
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
if (
(tool.name === ENTER_PLAN_MODE_TOOL_NAME && isPlanMode) ||
+1 -1
View File
@@ -350,7 +350,7 @@ export async function isEmpty(filePath: string): Promise<boolean> {
*/
export async function isBinaryFile(filePath: string): Promise<boolean> {
try {
return await isBinaryFileCheck(filePath);
return Boolean(await isBinaryFileCheck(filePath));
} catch (error) {
debugLogger.warn(
`Failed to check if file is binary: ${filePath}`,

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