diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..e79a88e890
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+# Git history - not needed in build context
+.git
+
+# Root node_modules - reinstalled inside container via npm ci
+node_modules
+
+# Package-level node_modules - reinstalled inside container
+packages/*/node_modules
+
+# Development and IDE files
+.github
+.vscode
+npm-debug.log*
+
+# Misc
+.DS_Store
+*.tmp
diff --git a/.gemini/commands/strict-development-rules.md b/.gemini/commands/strict-development-rules.md
index 6620c024ae..baec8be197 100644
--- a/.gemini/commands/strict-development-rules.md
+++ b/.gemini/commands/strict-development-rules.md
@@ -53,11 +53,27 @@ Gemini CLI project.
overriding values. Refer to `text-buffer.ts` for a canonical example.
- **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in
the code.
-- **State & Effects**: Ensure state initialization is explicit (e.g., use
- `undefined` rather than `true` as a default if the state is truly unknown).
- Carefully manage `useEffect` dependencies. Prefer a reducer whenever
- practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to
- correctly declare dependencies instead.
+- **State**: Ensure state initialization is explicit (e.g., use `undefined`
+ rather than `true` as a default if the state is truly unknown). Prefer a
+ reducer whenever practical. NEVER disable `react-hooks/exhaustive-deps`; fix
+ the code to correctly declare dependencies instead. Evaluate all the React
+ states in a component and ensure that the `useState` calls are necessary and
+ not cases where values could be derived on render. Ensure there are no stale
+ closures that are relying on a value from a previous render. React Components
+ that modify Settings should effectively use the `useSettingsStore` pattern.
+ Components that configure application Settings (e.g settings.json) are the
+ only reasonable case for unsaved changes to drive UX; in these cases, the
+ Settings store should only be written to on save. If the user experience does
+ not utilize unsaved changes because there is no option to exit without saving
+ or reverting the unsaved changes, then the component should directly read from
+ and write to the Settings store without holding pending changes in component
+ level UI state.
+- **Effect**: `useEffect` should not be used to synchronize React states, it
+ should only be used for genuine side effects that occur outside of React.
+ Contributors should be able to strongly justify the need for an effect.
+ Consider whether the effect should instead be inside an event handler, or
+ whether it is better off being computed on render. Carefully manage
+ `useEffect` dependencies.
- **Context & Props**: Avoid excessive property drilling. Leverage existing
providers, extend them, or propose a new one if necessary. Only use providers
for properties that are consistent across the entire application.
diff --git a/.gemini/settings.json b/.gemini/settings.json
index e7ff785b7c..850f9e26ce 100644
--- a/.gemini/settings.json
+++ b/.gemini/settings.json
@@ -3,7 +3,6 @@
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true,
- "gemma": true,
"memoryManager": true,
"topicUpdateNarration": true,
"voiceMode": true
diff --git a/.github/scripts/gemini-lifecycle-manager.cjs b/.github/scripts/gemini-lifecycle-manager.cjs
new file mode 100644
index 0000000000..6a32beeb53
--- /dev/null
+++ b/.github/scripts/gemini-lifecycle-manager.cjs
@@ -0,0 +1,244 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Gemini Scheduled Lifecycle Manager Script
+ * @param {object} param0
+ * @param {import('@octokit/rest').Octokit} param0.github
+ * @param {import('@actions/github/lib/context').Context} param0.context
+ * @param {import('@actions/core')} param0.core
+ */
+module.exports = async ({ github, context, core }) => {
+ const dryRun = process.env.DRY_RUN === 'true';
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ const STALE_LABEL = 'stale';
+ const NEED_INFO_LABEL = 'status/need-information';
+ const EXEMPT_LABELS = [
+ 'pinned',
+ 'security',
+ 'š maintainer only',
+ 'help wanted',
+ 'šļø Public Roadmap',
+ ];
+
+ const STALE_DAYS = 60;
+ const CLOSE_DAYS = 14;
+ const NO_RESPONSE_DAYS = 14;
+
+ const now = new Date();
+ const staleThreshold = new Date(
+ now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000,
+ );
+ const closeThreshold = new Date(
+ now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000,
+ );
+ const noResponseThreshold = new Date(
+ now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
+ );
+
+ async function processItems(query, callback) {
+ core.info(`Searching: ${query}`);
+ try {
+ const response = await github.rest.search.issuesAndPullRequests({
+ q: query,
+ per_page: 100,
+ sort: 'updated',
+ order: 'asc',
+ });
+ const items = response.data.items;
+ core.info(`Found ${items.length} items (batch limited).`);
+ for (const item of items) {
+ try {
+ await callback(item);
+ } catch (err) {
+ core.error(`Error processing #${item.number}: ${err.message}`);
+ }
+ }
+ } catch (err) {
+ core.error(`Search failed: ${err.message}`);
+ }
+ }
+
+ // 1. Handle No-Response (status/need-information)
+ // Removal: Check issues updated in the last 48h that have the label
+ const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
+ await processItems(
+ `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`,
+ async (item) => {
+ const { data: comments } = await github.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number: item.number,
+ sort: 'created',
+ direction: 'desc',
+ per_page: 5,
+ });
+
+ // Check if the last comment is from a non-maintainer
+ const lastComment = comments[0];
+ if (
+ lastComment &&
+ !['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
+ lastComment.author_association,
+ ) &&
+ lastComment.user?.type !== 'Bot'
+ ) {
+ core.info(
+ `Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
+ );
+ if (!dryRun) {
+ await github.rest.issues
+ .removeLabel({
+ owner,
+ repo,
+ issue_number: item.number,
+ name: NEED_INFO_LABEL,
+ })
+ .catch(() => {});
+ }
+ }
+ },
+ );
+
+ // Closure: Check issues with the label that haven't been updated in 14 days
+ await processItems(
+ `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
+ async (item) => {
+ core.info(
+ `Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
+ );
+ if (!dryRun) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: item.number,
+ body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`,
+ });
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: item.number,
+ state: 'closed',
+ });
+ }
+ },
+ );
+
+ // 2. Handle Stale Mark (60 days inactivity, no stale label)
+ const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
+ await processItems(
+ `repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
+ async (item) => {
+ core.info(`Marking #${item.number} as stale.`);
+ if (!dryRun) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: item.number,
+ labels: [STALE_LABEL],
+ });
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: item.number,
+ body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
+ });
+ }
+ },
+ );
+
+ // 3. Handle Stale Close (14 days with stale label)
+ await processItems(
+ `repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
+ async (item) => {
+ core.info(`Closing stale item #${item.number}.`);
+ if (!dryRun) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: item.number,
+ body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`,
+ });
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: item.number,
+ state: 'closed',
+ });
+ }
+ },
+ );
+
+ // 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
+ const PR_NUDGE_DAYS = 7;
+ const PR_CLOSE_DAYS = 14;
+ const nudgeThreshold = new Date(
+ now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000,
+ );
+ const prCloseThreshold = new Date(
+ now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000,
+ );
+
+ // Nudge
+ await processItems(
+ `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"š maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
+ async (pr) => {
+ if (
+ ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
+ pr.user?.type === 'Bot'
+ )
+ return;
+
+ core.info(`Nudging PR #${pr.number} for contribution policy.`);
+ if (!dryRun) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: pr.number,
+ labels: ['status/pr-nudge-sent'],
+ });
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: pr.number,
+ body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
+ });
+ }
+ },
+ );
+
+ // Close
+ await processItems(
+ `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"š maintainer only" created:<${prCloseThreshold.toISOString()}`,
+ async (pr) => {
+ if (
+ ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
+ pr.user?.type === 'Bot'
+ )
+ return;
+
+ core.info(
+ `Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
+ );
+ if (!dryRun) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: pr.number,
+ body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
+ });
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: pr.number,
+ state: 'closed',
+ });
+ }
+ },
+ );
+};
diff --git a/.github/workflows/gemini-lifecycle-manager.yml b/.github/workflows/gemini-lifecycle-manager.yml
new file mode 100644
index 0000000000..1de2565e8e
--- /dev/null
+++ b/.github/workflows/gemini-lifecycle-manager.yml
@@ -0,0 +1,45 @@
+name: 'š Gemini Scheduled Lifecycle Manager'
+
+on:
+ schedule:
+ - cron: '30 1 * * *' # Once a day
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Run in dry-run mode (no changes applied)'
+ required: false
+ default: false
+ type: 'boolean'
+
+concurrency:
+ group: '${{ github.workflow }}'
+ cancel-in-progress: true
+
+permissions:
+ issues: 'write'
+ pull-requests: 'write'
+
+jobs:
+ manage-lifecycle:
+ if: "github.repository == 'google-gemini/gemini-cli'"
+ runs-on: 'ubuntu-latest'
+ steps:
+ - name: 'Generate GitHub App Token'
+ id: 'generate_token'
+ uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
+ with:
+ app-id: '${{ secrets.APP_ID }}'
+ private-key: '${{ secrets.PRIVATE_KEY }}'
+
+ - name: 'Checkout repository'
+ uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
+
+ - name: 'Lifecycle Management'
+ uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
+ env:
+ DRY_RUN: '${{ inputs.dry_run }}'
+ with:
+ github-token: '${{ steps.generate_token.outputs.token }}'
+ script: |
+ const script = require('./.github/scripts/gemini-lifecycle-manager.cjs');
+ await script({github, context, core});
diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml
index 50dd56883e..f66724cd20 100644
--- a/.github/workflows/gemini-scheduled-issue-triage.yml
+++ b/.github/workflows/gemini-scheduled-issue-triage.yml
@@ -63,15 +63,15 @@ jobs:
echo 'š Finding issues missing area labels...'
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
- --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
+ --search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
echo 'š Finding issues missing kind labels...'
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
- --search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
+ --search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
echo 'š·ļø Finding issues missing priority labels...'
NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
- --search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
+ --search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
echo 'š Merging and deduplicating issues...'
ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml
deleted file mode 100644
index cfbecd6490..0000000000
--- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml
+++ /dev/null
@@ -1,159 +0,0 @@
-name: 'š Gemini Scheduled Stale Issue Closer'
-
-on:
- schedule:
- - cron: '0 0 * * 0' # Every Sunday at midnight UTC
- workflow_dispatch:
- inputs:
- dry_run:
- description: 'Run in dry-run mode (no changes applied)'
- required: false
- default: false
- type: 'boolean'
-
-concurrency:
- group: '${{ github.workflow }}'
- cancel-in-progress: true
-
-defaults:
- run:
- shell: 'bash'
-
-jobs:
- close-stale-issues:
- if: "github.repository == 'google-gemini/gemini-cli'"
- runs-on: 'ubuntu-latest'
- permissions:
- issues: 'write'
- steps:
- - name: 'Generate GitHub App Token'
- id: 'generate_token'
- uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
- with:
- app-id: '${{ secrets.APP_ID }}'
- private-key: '${{ secrets.PRIVATE_KEY }}'
- permission-issues: 'write'
-
- - name: 'Process Stale Issues'
- uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
- env:
- DRY_RUN: '${{ inputs.dry_run }}'
- with:
- github-token: '${{ steps.generate_token.outputs.token }}'
- script: |
- const dryRun = process.env.DRY_RUN === 'true';
- if (dryRun) {
- core.info('DRY RUN MODE ENABLED: No changes will be applied.');
- }
- const batchLabel = 'Stale';
-
- const threeMonthsAgo = new Date();
- threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
-
- const tenDaysAgo = new Date();
- tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
-
- core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`);
- core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`);
-
- const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`;
- core.info(`Searching with query: ${query}`);
-
- const itemsToCheck = await github.paginate(github.rest.search.issuesAndPullRequests, {
- q: query,
- sort: 'created',
- order: 'asc',
- per_page: 100
- });
-
- core.info(`Found ${itemsToCheck.length} open issues to check.`);
-
- let processedCount = 0;
-
- for (const issue of itemsToCheck) {
- const createdAt = new Date(issue.created_at);
- const updatedAt = new Date(issue.updated_at);
- const reactionCount = issue.reactions.total_count;
-
- // Basic thresholds
- if (reactionCount >= 5) {
- continue;
- }
-
- // Skip if it has a maintainer, help wanted, or Public Roadmap label
- const rawLabels = issue.labels.map((l) => l.name);
- const lowercaseLabels = rawLabels.map((l) => l.toLowerCase());
- if (
- lowercaseLabels.some((l) => l.includes('maintainer')) ||
- lowercaseLabels.includes('help wanted') ||
- rawLabels.includes('šļø Public Roadmap')
- ) {
- continue;
- }
-
- let isStale = updatedAt < tenDaysAgo;
-
- // If apparently active, check if it's only bot activity
- if (!isStale) {
- try {
- const comments = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- per_page: 100,
- sort: 'created',
- direction: 'desc'
- });
-
- const lastHumanComment = comments.data.find(comment => comment.user.type !== 'Bot');
- if (lastHumanComment) {
- isStale = new Date(lastHumanComment.created_at) < tenDaysAgo;
- } else {
- // No human comments. Check if creator is human.
- if (issue.user.type !== 'Bot') {
- isStale = createdAt < tenDaysAgo;
- } else {
- isStale = true; // Bot created, only bot comments
- }
- }
- } catch (error) {
- core.warning(`Failed to fetch comments for issue #${issue.number}: ${error.message}`);
- continue;
- }
- }
-
- if (isStale) {
- processedCount++;
- const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`;
- core.info(message);
-
- if (!dryRun) {
- // Add label
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: [batchLabel]
- });
-
- // Add comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!'
- });
-
- // Close issue
- await github.rest.issues.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- state: 'closed',
- state_reason: 'not_planned'
- });
- }
- }
- }
-
- core.info(`\nTotal issues processed: ${processedCount}`);
diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml
deleted file mode 100644
index 7a8e3c1fd5..0000000000
--- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml
+++ /dev/null
@@ -1,254 +0,0 @@
-name: 'Gemini Scheduled Stale PR Closer'
-
-on:
- schedule:
- - cron: '0 2 * * *' # Every day at 2 AM UTC
- pull_request:
- types: ['opened', 'edited']
- workflow_dispatch:
- inputs:
- dry_run:
- description: 'Run in dry-run mode'
- required: false
- default: false
- type: 'boolean'
-
-jobs:
- close-stale-prs:
- if: "github.repository == 'google-gemini/gemini-cli'"
- runs-on: 'ubuntu-latest'
- permissions:
- pull-requests: 'write'
- issues: 'write'
- steps:
- - name: 'Generate GitHub App Token'
- id: 'generate_token'
- env:
- APP_ID: '${{ secrets.APP_ID }}'
- if: |-
- ${{ env.APP_ID != '' }}
- uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
- with:
- app-id: '${{ secrets.APP_ID }}'
- private-key: '${{ secrets.PRIVATE_KEY }}'
-
- - name: 'Process Stale PRs'
- uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
- env:
- DRY_RUN: '${{ inputs.dry_run }}'
- with:
- github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
- script: |
- const dryRun = process.env.DRY_RUN === 'true';
- const fourteenDaysAgo = new Date();
- fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
- const thirtyDaysAgo = new Date();
- thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
-
- // 1. Fetch maintainers for verification
- let maintainerLogins = new Set();
- const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
-
- for (const team_slug of teams) {
- try {
- const members = await github.paginate(github.rest.teams.listMembersInOrg, {
- org: context.repo.owner,
- team_slug: team_slug
- });
- for (const m of members) maintainerLogins.add(m.login.toLowerCase());
- core.info(`Successfully fetched ${members.length} team members from ${team_slug}`);
- } catch (e) {
- // Silently skip if permissions are insufficient; we will rely on author_association
- core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`);
- }
- }
-
- const isMaintainer = async (login, assoc) => {
- // Reliably identify maintainers using authorAssociation (provided by GitHub)
- // and organization membership (if available).
- const isTeamMember = maintainerLogins.has(login.toLowerCase());
- const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
-
- if (isTeamMember || isRepoMaintainer) return true;
-
- // Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission)
- try {
- const orgs = ['googlers', 'google'];
- for (const org of orgs) {
- try {
- await github.rest.orgs.checkMembershipForUser({ org: org, username: login });
- return true;
- } catch (e) {
- if (e.status !== 404) throw e;
- }
- }
- } catch (e) {
- // Gracefully ignore failures here
- }
-
- return false;
- };
-
- // 2. Fetch all open PRs
- let prs = [];
- if (context.eventName === 'pull_request') {
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.payload.pull_request.number
- });
- prs = [pr];
- } else {
- prs = await github.paginate(github.rest.pulls.list, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- state: 'open',
- per_page: 100
- });
- }
-
- for (const pr of prs) {
- const maintainerPr = await isMaintainer(pr.user.login, pr.author_association);
- const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
- if (maintainerPr || isBot) continue;
-
- // Helper: Fetch labels and linked issues via GraphQL
- const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) {
- repository(owner:$owner, name:$repo) {
- pullRequest(number:$number) {
- closingIssuesReferences(first: 10) {
- nodes {
- number
- labels(first: 20) {
- nodes { name }
- }
- }
- }
- }
- }
- }`;
-
- let linkedIssues = [];
- try {
- const res = await github.graphql(prDetailsQuery, {
- owner: context.repo.owner, repo: context.repo.repo, number: pr.number
- });
- linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes;
- } catch (e) {
- core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`);
- }
-
- // Check for mentions in body as fallback (regex)
- const body = pr.body || '';
- const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
- const matches = body.match(mentionRegex);
- if (matches && linkedIssues.length === 0) {
- const issueNumber = parseInt(matches[1]);
- try {
- const { data: issue } = await github.rest.issues.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issueNumber
- });
- linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }];
- } catch (e) {}
- }
-
- // 3. Enforcement Logic
- const prLabels = pr.labels.map(l => l.name.toLowerCase());
- const hasHelpWanted = prLabels.includes('help wanted') ||
- linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted'));
-
- const hasMaintainerOnly = prLabels.includes('š maintainer only') ||
- linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'š maintainer only'));
-
- const hasLinkedIssue = linkedIssues.length > 0;
-
- // Closure Policy: No help-wanted label = Close after 14 days
- if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) {
- const prCreatedAt = new Date(pr.created_at);
-
- // We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM
- if (prCreatedAt > fourteenDaysAgo) {
- core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`);
- continue;
- }
-
- core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`);
- if (!dryRun) {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr.number,
- body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!"
- });
- await github.rest.pulls.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: pr.number,
- state: 'closed'
- });
- }
- continue;
- }
-
- // Also check for linked issue even if it has help wanted (redundant but safe)
- if (pr.state === 'open' && !hasLinkedIssue) {
- // Already covered by hasHelpWanted check above, but good for future-proofing
- continue;
- }
-
- // 4. Staleness Check (Scheduled only)
- if (pr.state === 'open' && context.eventName !== 'pull_request') {
- // Skip PRs that were created less than 30 days ago - they cannot be stale yet
- const prCreatedAt = new Date(pr.created_at);
- if (prCreatedAt > thirtyDaysAgo) continue;
-
- let lastActivity = new Date(pr.created_at);
- try {
- const reviews = await github.paginate(github.rest.pulls.listReviews, {
- owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
- });
- for (const r of reviews) {
- if (await isMaintainer(r.user.login, r.author_association)) {
- const d = new Date(r.submitted_at || r.updated_at);
- if (d > lastActivity) lastActivity = d;
- }
- }
- const comments = await github.paginate(github.rest.issues.listComments, {
- owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number
- });
- for (const c of comments) {
- if (await isMaintainer(c.user.login, c.author_association)) {
- const d = new Date(c.updated_at);
- if (d > lastActivity) lastActivity = d;
- }
- }
- } catch (e) {}
-
- if (lastActivity < thirtyDaysAgo) {
- const labels = pr.labels.map(l => l.name.toLowerCase());
- const isProtected = labels.includes('help wanted') || labels.includes('š maintainer only');
- if (isProtected) {
- core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`);
- continue;
- }
-
- core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`);
- if (!dryRun) {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr.number,
- body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!"
- });
- await github.rest.pulls.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: pr.number,
- state: 'closed'
- });
- }
- }
- }
- }
diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml
deleted file mode 100644
index abaad9dbbf..0000000000
--- a/.github/workflows/no-response.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: 'No Response'
-
-# Run as a daily cron at 1:45 AM
-on:
- schedule:
- - cron: '45 1 * * *'
- workflow_dispatch:
-
-jobs:
- no-response:
- runs-on: 'ubuntu-latest'
- if: |-
- ${{ github.repository == 'google-gemini/gemini-cli' }}
- permissions:
- issues: 'write'
- pull-requests: 'write'
- concurrency:
- group: '${{ github.workflow }}-no-response'
- cancel-in-progress: true
- steps:
- - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
- with:
- repo-token: '${{ secrets.GITHUB_TOKEN }}'
- days-before-stale: -1
- days-before-close: 14
- stale-issue-label: 'status/need-information'
- close-issue-message: >-
- This issue was marked as needing more information and has not received a response in 14 days.
- Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
- stale-pr-label: 'status/need-information'
- close-pr-message: >-
- This pull request was marked as needing more information and has had no updates in 14 days.
- Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!
diff --git a/.github/workflows/pr-contribution-guidelines-notifier.yml b/.github/workflows/pr-contribution-guidelines-notifier.yml
deleted file mode 100644
index bd08aac0ce..0000000000
--- a/.github/workflows/pr-contribution-guidelines-notifier.yml
+++ /dev/null
@@ -1,133 +0,0 @@
-name: 'š·ļø PR Contribution Guidelines Notifier'
-
-on:
- pull_request:
- types:
- - 'opened'
-
-jobs:
- notify-process-change:
- runs-on: 'ubuntu-latest'
- if: |-
- github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli'
- permissions:
- pull-requests: 'write'
- steps:
- - name: 'Generate GitHub App Token'
- id: 'generate_token'
- env:
- APP_ID: '${{ secrets.APP_ID }}'
- if: |-
- ${{ env.APP_ID != '' }}
- uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
- with:
- app-id: '${{ secrets.APP_ID }}'
- private-key: '${{ secrets.PRIVATE_KEY }}'
-
- - name: 'Check membership and post comment'
- uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
- with:
- github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
- script: |-
- const org = context.repo.owner;
- const repo = context.repo.repo;
- const username = context.payload.pull_request.user.login;
- const pr_number = context.payload.pull_request.number;
-
- // 1. Check if the PR author is a maintainer
- // Check team membership (most reliable for private org members)
- let isTeamMember = false;
- const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
- for (const team_slug of teams) {
- try {
- const members = await github.paginate(github.rest.teams.listMembersInOrg, {
- org: org,
- team_slug: team_slug
- });
- if (members.some(m => m.login.toLowerCase() === username.toLowerCase())) {
- isTeamMember = true;
- core.info(`${username} is a member of ${team_slug}. No notification needed.`);
- break;
- }
- } catch (e) {
- core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`);
- }
- }
-
- if (isTeamMember) return;
-
- // Check author_association from webhook payload
- const authorAssociation = context.payload.pull_request.author_association;
- const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation);
-
- if (isRepoMaintainer) {
- core.info(`${username} is a maintainer (author_association: ${authorAssociation}). No notification needed.`);
- return;
- }
-
- // Check if author is a Googler
- const isGoogler = async (login) => {
- try {
- const orgs = ['googlers', 'google'];
- for (const org of orgs) {
- try {
- await github.rest.orgs.checkMembershipForUser({
- org: org,
- username: login
- });
- return true;
- } catch (e) {
- if (e.status !== 404) throw e;
- }
- }
- } catch (e) {
- core.warning(`Failed to check org membership for ${login}: ${e.message}`);
- }
- return false;
- };
-
- if (await isGoogler(username)) {
- core.info(`${username} is a Googler. No notification needed.`);
- return;
- }
-
- // 2. Check if the PR is already associated with an issue
- const query = `
- query($owner:String!, $repo:String!, $number:Int!) {
- repository(owner:$owner, name:$repo) {
- pullRequest(number:$number) {
- closingIssuesReferences(first: 1) {
- totalCount
- }
- }
- }
- }
- `;
- const variables = { owner: org, repo: repo, number: pr_number };
- const result = await github.graphql(query, variables);
- const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount;
-
- if (issueCount > 0) {
- core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`);
- return;
- }
-
- // 3. Post the notification comment
- core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`);
-
- const comment = `
- Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.
-
- We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706).
-
- Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.
-
- Thank you for your understanding and for being a part of our community!
- `.trim().replace(/^[ ]+/gm, '');
-
- await github.rest.issues.createComment({
- owner: org,
- repo: repo,
- issue_number: pr_number,
- body: comment
- });
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
deleted file mode 100644
index 4a975869f5..0000000000
--- a/.github/workflows/stale.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: 'Mark stale issues and pull requests'
-
-# Run as a daily cron at 1:30 AM
-on:
- schedule:
- - cron: '30 1 * * *'
- workflow_dispatch:
-
-jobs:
- stale:
- strategy:
- fail-fast: false
- matrix:
- runner:
- - 'ubuntu-latest' # GitHub-hosted
- runs-on: '${{ matrix.runner }}'
- if: |-
- ${{ github.repository == 'google-gemini/gemini-cli' }}
- permissions:
- issues: 'write'
- pull-requests: 'write'
- concurrency:
- group: '${{ github.workflow }}-stale'
- cancel-in-progress: true
- steps:
- - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
- with:
- repo-token: '${{ secrets.GITHUB_TOKEN }}'
- stale-issue-message: >-
- This issue has been automatically marked as stale due to 60 days of inactivity.
- It will be closed in 14 days if no further activity occurs.
- stale-pr-message: >-
- This pull request has been automatically marked as stale due to 60 days of inactivity.
- It will be closed in 14 days if no further activity occurs.
- close-issue-message: >-
- This issue has been closed due to 14 additional days of inactivity after being marked as stale.
- If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
- close-pr-message: >-
- This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
- If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
- days-before-stale: 60
- days-before-close: 14
- exempt-issue-labels: 'pinned,security,š maintainer only,help wanted,šļø Public Roadmap'
- exempt-pr-labels: 'pinned,security,š maintainer only,help wanted,šļø Public Roadmap'
diff --git a/Dockerfile b/Dockerfile
index 44ba343902..31d9c6d446 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,44 @@
+# ---- Stage 1: Builder ----
+FROM docker.io/library/node:20-slim AS builder
+
+# Install git (needed by generate-git-commit-info.js script)
+RUN apt-get update && apt-get install -y --no-install-recommends git \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+# Copy only package.json files first for better layer caching
+# Dependencies only re-install when package files change, not source files
+COPY package*.json ./
+COPY packages/cli/package*.json ./packages/cli/
+COPY packages/core/package*.json ./packages/core/
+COPY packages/vscode-ide-companion/package*.json ./packages/vscode-ide-companion/
+COPY packages/vscode-ide-companion/scripts/ ./packages/vscode-ide-companion/scripts/
+COPY packages/devtools/package*.json ./packages/devtools/
+COPY packages/sdk/package*.json ./packages/sdk/
+COPY packages/test-utils/package*.json ./packages/test-utils/
+COPY packages/a2a-server/package*.json ./packages/a2a-server/
+
+# Use npm ci for consistent, reliable builds (respects package-lock.json)
+RUN HUSKY=0 npm ci --ignore-scripts
+
+# Now copy the rest of the source (after install for better caching)
+COPY packages/ ./packages/
+COPY tsconfig*.json ./
+COPY eslint.config.js ./
+COPY scripts/ ./scripts/
+COPY esbuild.config.js ./
+
+# Pass git commit hash as build arg instead of copying entire .git directory
+ARG GIT_COMMIT=unknown
+ENV GIT_COMMIT=$GIT_COMMIT
+
+# Build and pack artifacts
+RUN HUSKY=0 npm run build && \
+ npm pack -w packages/core --pack-destination packages/core/dist/ && \
+ npm pack -w packages/cli --pack-destination packages/cli/dist/
+
+# ---- Stage 2: Runtime ----
FROM docker.io/library/node:20-slim
ARG SANDBOX_NAME="gemini-cli-sandbox"
@@ -50,4 +91,4 @@ RUN npm install -g /tmp/gemini-core.tgz \
&& rm -f /tmp/gemini-{cli,core}.tgz
# default entrypoint when none specified
-CMD ["gemini"]
+ENTRYPOINT ["/usr/local/share/npm-global/bin/gemini"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 885b9d7429..20813de3eb 100644
--- a/README.md
+++ b/README.md
@@ -395,6 +395,16 @@ for removal instructions.
[Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy)
- **Security**: [Security Policy](SECURITY.md)
+
+
+
+
+
+
+
+
+
+
---
diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md
index 259edcec1f..a3d17c0a77 100644
--- a/docs/cli/cli-reference.md
+++ b/docs/cli/cli-reference.md
@@ -9,7 +9,7 @@ and parameters.
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `gemini` | Start interactive REPL | `gemini` |
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
-| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
+| gemini "query" | Query and continue interactively | gemini "explain this project" |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index 834750fdf9..c5e8a3d51b 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -158,15 +158,16 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` |
+| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` |
### Experimental
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
-| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
+| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
-| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
+| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
@@ -177,7 +178,7 @@ they appear in the UI.
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit ā settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
-| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
+| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `/.inbox//` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 7bdd43997e..0897a69fa0 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -1752,6 +1752,12 @@ their corresponding top-level category object in your `settings.json` file.
["DEBUG", "DEBUG_MODE"]
```
+- **`advanced.ignoreLocalEnv`** (boolean):
+ - **Description:** Whether to ignore generic .env files in the project
+ directory.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
- **`advanced.bugCommand`** (object):
- **Description:** Configuration for the bug report command.
- **Default:** `undefined`
@@ -1759,8 +1765,8 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.gemma`** (boolean):
- - **Description:** Enable access to Gemma 4 models (experimental).
- - **Default:** `false`
+ - **Description:** Enable access to Gemma 4 models via Gemini API.
+ - **Default:** `true`
- **Requires restart:** Yes
- **`experimental.voiceMode`** (boolean):
@@ -1774,7 +1780,9 @@ their corresponding top-level category object in your `settings.json` file.
- **Values:** `"push-to-talk"`, `"toggle"`
- **`experimental.voice.backend`** (enum):
- - **Description:** The backend to use for voice transcription.
+ - **Description:** The backend to use for voice transcription. Note: When
+ using the Gemini Live backend, voice recordings are sent to Google Cloud for
+ transcription.
- **Default:** `"gemini-live"`
- **Values:** `"gemini-live"`, `"whisper"`
@@ -1925,8 +1933,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.autoMemory`** (boolean):
- - **Description:** Automatically extract reusable skills from past sessions in
- the background. Review results with /memory inbox.
+ - **Description:** Automatically extract memory patches and skills from past
+ sessions in the background. Every change is written as a unified diff
+ `.patch` file under `/.inbox//` and held for review
+ in /memory inbox; nothing is applied until you approve it.
- **Default:** `false`
- **Requires restart:** Yes
@@ -2580,7 +2590,6 @@ for that specific session.
- **Note:** For structured output and scripting, use the
`--output-format json` or `--output-format stream-json` flag.
- **`--prompt `** (**`-p `**):
- - **Deprecated:** Use positional arguments instead.
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
non-interactive mode.
- **`--prompt-interactive `** (**`-i `**):
diff --git a/docs/reference/tools.md b/docs/reference/tools.md
index 6236225d88..779317a506 100644
--- a/docs/reference/tools.md
+++ b/docs/reference/tools.md
@@ -154,6 +154,55 @@ each tool.
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (for example, localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. |
+### Tool argument keys
+
+When writing [`argsPattern`](./policy-engine.md#arguments-pattern) rules for the
+[policy engine](./policy-engine.md), you need to know the JSON argument keys for
+each tool. The following table lists the keys that appear in the JSON
+representation of each tool's arguments.
+
+| Tool | JSON argument keys |
+| :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `run_shell_command` | `command`, `description`, `dir_path`, `is_background` |
+| `glob` | `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
+| `grep_search` | `pattern`, `dir_path`, `include_pattern`, `exclude_pattern`, `names_only`, `case_sensitive`, `fixed_strings`, `context`, `after`, `before`, `no_ignore`, `max_matches_per_file`, `total_max_matches` |
+| `list_directory` | `dir_path`, `ignore`, `file_filtering_options` |
+| `read_file` | `file_path`, `start_line`, `end_line` |
+| `read_many_files` | `include`, `exclude`, `recursive`, `useDefaultExcludes` |
+| `write_file` | `file_path`, `content` |
+| `replace` | `file_path`, `old_string`, `new_string`, `instruction`, `allow_multiple` |
+| `ask_user` | `questions` (array of `question`, `header`, `type`, `options`) |
+| `write_todos` | `todos` (array of `description`, `status`) |
+| `save_memory` | `fact` |
+| `activate_skill` | `name` |
+| `get_internal_docs` | `path` |
+| `enter_plan_mode` | `reason` |
+| `exit_plan_mode` | `plan_path` |
+| `tracker_create_task` | `title`, `description`, `type` |
+| `tracker_update_task` | `id`, `title`, `description`, `status`, `dependencies` |
+| `tracker_get_task` | `id` |
+| `tracker_list_tasks` | `status`, `type`, `parentId` |
+| `tracker_add_dependency` | `taskId`, `dependencyId` |
+| `tracker_visualize` | _(none)_ |
+| `update_topic` | `title`, `summary`, `strategic_intent` |
+| `google_web_search` | `query` |
+| `web_fetch` | `prompt` |
+
+For example, to write a policy rule that blocks any `write_file` call targeting
+a `.env` file, you would match against the `file_path` key:
+
+```toml
+[[rule]]
+toolName = "write_file"
+argsPattern = '"file_path":".*\.env"'
+decision = "deny"
+priority = 100
+denyMessage = "Writing to .env files is not allowed."
+```
+
+For full argument descriptions and types, see the individual tool pages linked
+in the [tables above](#available-tools).
+
## Under the hood
For developers, the tool system is designed to be extensible and robust. The
diff --git a/docs/releases.md b/docs/releases.md
index 7969535960..90a218b7f2 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -8,7 +8,7 @@
Our release flows support both `dev` and `prod` environments.
-The `dev` environment pushes to a private Github-hosted NPM repository, with the
+The `dev` environment pushes to a private GitHub-hosted NPM repository, with the
package names beginning with `@google-gemini/**` instead of `@google/**`.
The `prod` environment pushes to the public global NPM registry via Wombat
@@ -20,7 +20,7 @@ More information can be found about these systems in the
### Package scopes
-| Package | `prod` (Wombat Dressing Room) | `dev` (Github Private NPM Repo) |
+| Package | `prod` (Wombat Dressing Room) | `dev` (GitHub Private NPM Repo) |
| ---------- | ----------------------------- | ----------------------------------------- |
| CLI | @google/gemini-cli | @google-gemini/gemini-cli |
| Core | @google/gemini-cli-core | @google-gemini/gemini-cli-core A2A Server |
diff --git a/docs/tools/shell.md b/docs/tools/shell.md
index 84bb76e393..e3df7a4c52 100644
--- a/docs/tools/shell.md
+++ b/docs/tools/shell.md
@@ -19,6 +19,23 @@ platforms, they execute with `bash -c`.
- `is_background` (boolean, optional): Whether to move the process to the
background immediately after starting.
+### Policy engine shorthands
+
+The [policy engine](../reference/policy-engine.md) provides two convenience
+fields for writing rules that target shell commands:
+
+- `commandPrefix`: Matches if the `command` argument starts with a given string.
+- `commandRegex`: Matches if the `command` argument matches a given regular
+ expression.
+
+These are syntactic sugar for combining `toolName = "run_shell_command"` with an
+`argsPattern` in a policy TOML file. They are **not** arguments of
+`run_shell_command` itself.
+
+For details on writing shell-specific policy rules, see
+[Special syntax for `run_shell_command`](../reference/policy-engine.md#special-syntax-for-run_shell_command)
+in the policy engine reference.
+
### Return values
The tool returns a JSON object containing:
diff --git a/eslint.config.js b/eslint.config.js
index aa3b5ae195..86f1f6740b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -54,6 +54,7 @@ export default tseslint.config(
ignores: [
'**/node_modules/**',
'eslint.config.js',
+ '**/coverage/**',
'packages/**/dist/**',
'bundle/**',
'package/bundle/**',
diff --git a/evals/auto_memory_contract.eval.ts b/evals/auto_memory_contract.eval.ts
new file mode 100644
index 0000000000..072a9d52b7
--- /dev/null
+++ b/evals/auto_memory_contract.eval.ts
@@ -0,0 +1,489 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Live-LLM evals that pin down the auto-memory inbox contract:
+ * 1. Canonical filename ā agent uses `.inbox//extraction.patch`.
+ * 2. Incremental merge ā agent rewrites an existing extraction.patch
+ * instead of creating new patch files alongside.
+ * 3. Absolute-path pointers ā when the agent creates a sibling .md, the
+ * paired MEMORY.md hunk references it by absolute path.
+ * 4. Project-root protection ā agent never writes to
+ * `/GEMINI.md` even when content is team-shared.
+ *
+ * Each test seeds session transcripts with strong, consistent signal so the
+ * extraction agent will reasonably produce SOME output (or, in the human-only
+ * test, refrain from producing output that targets forbidden paths). Tests
+ * are USUALLY_PASSES policy because LLM behavior is stochastic; the harness
+ * already retries up to 3 times.
+ */
+
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import { describe, expect } from 'vitest';
+import {
+ type Config,
+ ApprovalMode,
+ SESSION_FILE_PREFIX,
+ getProjectHash,
+ startMemoryService,
+} from '@google/gemini-cli-core';
+import { componentEvalTest } from './component-test-helper.js';
+
+interface SeedSession {
+ sessionId: string;
+ summary: string;
+ userTurns: string[];
+ /** Minutes ago the session ended (must be ā„ 180 to clear the idle gate). */
+ timestampOffsetMinutes: number;
+}
+
+interface MessageRecord {
+ id: string;
+ timestamp: string;
+ type: string;
+ content: Array<{ text: string }>;
+}
+
+const WORKSPACE_FILES = {
+ 'package.json': JSON.stringify(
+ {
+ name: 'auto-memory-contract-eval',
+ private: true,
+ scripts: { build: 'echo build', test: 'echo test' },
+ },
+ null,
+ 2,
+ ),
+ 'README.md': '# Auto Memory Contract Eval\n\nFixture workspace.\n',
+};
+
+const EXTRACTION_CONFIG_OVERRIDES = {
+ experimentalAutoMemory: true,
+ approvalMode: ApprovalMode.YOLO,
+};
+
+function buildMessages(userTurns: string[]): MessageRecord[] {
+ const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
+ return userTurns.flatMap((text, index) => [
+ {
+ id: `u${index + 1}`,
+ timestamp: baseTime,
+ type: 'user',
+ content: [{ text }],
+ },
+ {
+ id: `a${index + 1}`,
+ timestamp: baseTime,
+ type: 'gemini',
+ content: [{ text: 'Acknowledged.' }],
+ },
+ ]);
+}
+
+async function seedSessions(
+ config: Config,
+ sessions: SeedSession[],
+): Promise {
+ const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
+ await fsp.mkdir(chatsDir, { recursive: true });
+ const projectRoot = config.storage.getProjectRoot();
+
+ for (const session of sessions) {
+ const sessionTimestamp = new Date(
+ Date.now() - session.timestampOffsetMinutes * 60 * 1000,
+ );
+ const timestamp = sessionTimestamp
+ .toISOString()
+ .slice(0, 16)
+ .replace(/:/g, '-');
+ const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
+ const conversation = {
+ sessionId: session.sessionId,
+ projectHash: getProjectHash(projectRoot),
+ summary: session.summary,
+ startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
+ lastUpdated: sessionTimestamp.toISOString(),
+ messages: buildMessages(session.userTurns),
+ };
+ await fsp.writeFile(
+ path.join(chatsDir, filename),
+ JSON.stringify(conversation, null, 2),
+ );
+ }
+}
+
+interface InboxSnapshot {
+ privateFiles: string[];
+ globalFiles: string[];
+ privateContents: Map;
+}
+
+async function snapshotInbox(config: Config): Promise {
+ const memoryDir = config.storage.getProjectMemoryTempDir();
+ const inbox: InboxSnapshot = {
+ privateFiles: [],
+ globalFiles: [],
+ privateContents: new Map(),
+ };
+ for (const kind of ['private', 'global'] as const) {
+ const dir = path.join(memoryDir, '.inbox', kind);
+ let entries: string[];
+ try {
+ entries = await fsp.readdir(dir);
+ } catch {
+ continue;
+ }
+ const patchFiles = entries.filter((f) => f.endsWith('.patch')).sort();
+ if (kind === 'private') {
+ inbox.privateFiles = patchFiles;
+ for (const fileName of patchFiles) {
+ try {
+ inbox.privateContents.set(
+ fileName,
+ await fsp.readFile(path.join(dir, fileName), 'utf-8'),
+ );
+ } catch {
+ // ignore
+ }
+ }
+ } else {
+ inbox.globalFiles = patchFiles;
+ }
+ }
+ return inbox;
+}
+
+describe('Auto Memory Contract', () => {
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'auto-memory-contract',
+ suiteType: 'component-level',
+ name: 'uses canonical extraction.patch filename when writing private memory',
+ files: WORKSPACE_FILES,
+ timeout: 240000,
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ setup: async (config) => {
+ await seedSessions(config, [
+ {
+ sessionId: 'verify-memory-cmd-1',
+ summary:
+ 'Confirm that this project verifies memory edits with `npm run verify:memory`',
+ timestampOffsetMinutes: 420,
+ userTurns: [
+ 'For this project, every memory-system change is verified with `npm run verify:memory` before we hand the change back.',
+ 'That command is the gate. Without it the change is not considered done.',
+ 'It runs typechecks, the related unit tests, and a snapshot diff.',
+ 'Future agents working on memory should always run it after editing memoryService or commands/memory.ts.',
+ 'This is a durable rule for this project, not a one-off.',
+ 'The check is fast, under a minute, and failure means revert.',
+ 'Treat it as part of the memory subsystem contract.',
+ 'I want this remembered for next time.',
+ 'It applies to anything in packages/core/src/services/memoryService.ts and packages/core/src/commands/memory.ts.',
+ 'Make sure agents do not skip the verify step.',
+ ],
+ },
+ {
+ sessionId: 'verify-memory-cmd-2',
+ summary: 'Same memory-verify command in another session',
+ timestampOffsetMinutes: 360,
+ userTurns: [
+ 'I had to remind the previous agent to run `npm run verify:memory` again.',
+ 'It is the durable verification command for memory edits in this repo.',
+ 'The agent forgot, even though we agreed last time.',
+ 'Please remember it for future memory-related work.',
+ 'It is the official verification step for memory changes.',
+ 'Run it whenever you touch memoryService.ts or commands/memory.ts.',
+ 'No exceptions. The command must finish green.',
+ 'This is a recurring rule across multiple sessions now.',
+ 'Make this part of your standard workflow for memory work.',
+ 'Verified again that the command catches regressions in MEMORY.md handling.',
+ ],
+ },
+ ]);
+ },
+ assert: async (config) => {
+ await startMemoryService(config);
+ const inbox = await snapshotInbox(config);
+
+ // Either the agent extracted nothing (acceptable no-op) OR it extracted
+ // exactly one canonical file per kind. Multiple files per kind violates
+ // the contract.
+ expect(inbox.privateFiles.length).toBeLessThanOrEqual(1);
+ expect(inbox.globalFiles.length).toBeLessThanOrEqual(1);
+
+ // Strong assertion: when the agent DID write a private patch, it must
+ // be the canonical filename.
+ if (inbox.privateFiles.length === 1) {
+ expect(inbox.privateFiles[0]).toBe('extraction.patch');
+ }
+ if (inbox.globalFiles.length === 1) {
+ expect(inbox.globalFiles[0]).toBe('extraction.patch');
+ }
+ },
+ });
+
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'auto-memory-contract',
+ suiteType: 'component-level',
+ name: 'merges new findings into existing extraction.patch instead of creating new files',
+ files: WORKSPACE_FILES,
+ timeout: 240000,
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ setup: async (config) => {
+ const memoryDir = config.storage.getProjectMemoryTempDir();
+ const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
+ await fsp.mkdir(inboxPrivate, { recursive: true });
+
+ // Pre-existing canonical patch left over from a prior session.
+ const existingMemoryMd = path.join(memoryDir, 'MEMORY.md');
+ const preExistingPatch = [
+ `--- /dev/null`,
+ `+++ ${existingMemoryMd}`,
+ `@@ -0,0 +1,3 @@`,
+ `+# Project Memory`,
+ `+`,
+ `+- This project lints with \`npm run lint\` (recurring rule from session 1).`,
+ ``,
+ ].join('\n');
+ await fsp.writeFile(
+ path.join(inboxPrivate, 'extraction.patch'),
+ preExistingPatch,
+ );
+
+ // New session that surfaces a different durable fact.
+ await seedSessions(config, [
+ {
+ sessionId: 'incremental-typecheck-cmd',
+ summary:
+ 'Confirm that typecheck for memory edits uses `npm run typecheck`',
+ timestampOffsetMinutes: 420,
+ userTurns: [
+ 'Always run `npm run typecheck` after editing any *.ts file in this repo.',
+ 'It is the standard typecheck command for the whole monorepo.',
+ 'Future agents should follow this without being reminded.',
+ 'It catches type errors before tests, much faster.',
+ 'Run it on every TypeScript edit, no exceptions.',
+ 'This is durable across the whole project.',
+ 'It is the project-wide convention for TS work.',
+ 'Make sure to run it after edits to memoryService.ts especially.',
+ 'It is fast and catches regressions early.',
+ 'Treat it as standard workflow.',
+ ],
+ },
+ ]);
+ },
+ assert: async (config) => {
+ await startMemoryService(config);
+ const inbox = await snapshotInbox(config);
+
+ // Contract: still ONLY ONE file in private inbox, and its name is the
+ // canonical extraction.patch.
+ expect(inbox.privateFiles).toEqual(['extraction.patch']);
+
+ // The single canonical patch must STILL contain the old hunk (the
+ // agent must merge with existing rather than replace blindly), AND
+ // ideally also contain the new typecheck fact.
+ const merged = inbox.privateContents.get('extraction.patch') ?? '';
+ expect(merged).toMatch(/npm run lint/);
+ // Soft assertion: the agent SHOULD have added the new fact too. We
+ // don't fail the test if it didn't (the agent may legitimately decide
+ // the new fact isn't durable enough), but the file must be intact.
+ // The hard assertion (no proliferation + old content preserved) is
+ // what we lock down.
+ },
+ });
+
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'auto-memory-contract',
+ suiteType: 'component-level',
+ name: 'uses absolute paths in MEMORY.md sibling pointer lines',
+ files: WORKSPACE_FILES,
+ timeout: 240000,
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ setup: async (config) => {
+ // Sessions whose extracted memory has substantial detail ā encourages
+ // the agent to spawn a sibling .md file (per prompt guidance).
+ await seedSessions(config, [
+ {
+ sessionId: 'detailed-release-workflow-1',
+ summary: 'Detailed release workflow that runs across multiple steps',
+ timestampOffsetMinutes: 420,
+ userTurns: [
+ 'Our release workflow has several distinct phases that future agents need to follow exactly.',
+ 'Phase 1 (preflight): run `npm run lint`, `npm run typecheck`, and `npm test` in that order.',
+ 'Phase 2 (build): run `npm run build` and verify dist/ outputs against a checksum file.',
+ 'Phase 3 (publish): run `npm run publish:dry-run` first, then `npm run publish` if no errors.',
+ 'Phase 4 (post): tag the commit with `git tag v$(jq -r .version package.json)` and push.',
+ 'There are pitfalls: phase 2 will silently succeed if dist/ is stale, so always check the checksum.',
+ 'Phase 3 must NEVER be skipped for hotfixes; the dry-run catches credential issues.',
+ 'The checklist is durable across all releases for this repo.',
+ 'Future agents should reproduce these phases in order without omitting any.',
+ 'This is the canonical release procedure for this project.',
+ ],
+ },
+ {
+ sessionId: 'detailed-release-workflow-2',
+ summary: 'Reusing the same multi-phase release workflow',
+ timestampOffsetMinutes: 360,
+ userTurns: [
+ 'I just ran the release workflow again and it caught an issue in phase 2 because the checksum mismatched.',
+ 'Confirms the durable rule: always check the dist/ checksum after building.',
+ 'The 4-phase release procedure (preflight, build, publish, post) is the recurring workflow.',
+ 'I want this captured as durable memory because we use it every release.',
+ 'Each phase has multiple sub-steps and pitfalls, so it deserves substantial detail.',
+ 'Please remember the phases for future agents.',
+ 'The procedure has been the same for the last 6 releases.',
+ 'It includes the verify-checksum step that just saved us from a bad publish.',
+ 'This is a recurring multi-step workflow, not a one-off.',
+ 'Make sure future sessions know about all 4 phases and their pitfalls.',
+ ],
+ },
+ ]);
+ },
+ assert: async (config) => {
+ await startMemoryService(config);
+ const inbox = await snapshotInbox(config);
+ const memoryDir = config.storage.getProjectMemoryTempDir();
+
+ // The agent might choose to add brief facts directly to MEMORY.md
+ // without spawning a sibling. That's a valid outcome; we only enforce
+ // the absolute-path rule WHEN a sibling is created.
+ if (inbox.privateFiles.length === 0) {
+ return; // No-op extraction: nothing to assert.
+ }
+ expect(inbox.privateFiles).toEqual(['extraction.patch']);
+
+ const patch = inbox.privateContents.get('extraction.patch') ?? '';
+
+ // Find any /dev/null sibling-creation hunk that targets /.md
+ // (where x != MEMORY).
+ const siblingPattern = new RegExp(
+ `\\+\\+\\+ ${memoryDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}/([^\\s/]+)\\.md`,
+ 'g',
+ );
+ const siblingTargets: string[] = [];
+ let match: RegExpExecArray | null;
+ while ((match = siblingPattern.exec(patch)) !== null) {
+ const name = match[1];
+ // Skip MEMORY.md updates (those aren't siblings).
+ if (name.toLowerCase() !== 'memory') {
+ siblingTargets.push(`${name}.md`);
+ }
+ }
+
+ if (siblingTargets.length === 0) {
+ return; // No sibling creations; nothing more to check.
+ }
+
+ // For each created sibling, the patch must contain a MEMORY.md
+ // pointer line that uses the ABSOLUTE path. Bare basename references
+ // are the bug we're guarding against.
+ for (const sibling of siblingTargets) {
+ const absolutePath = path.join(memoryDir, sibling);
+ // Look for an added line referencing the sibling.
+ const addedLines = patch
+ .split('\n')
+ .filter((line) => line.startsWith('+'));
+ const referencingLines = addedLines.filter((line) =>
+ line.includes(sibling),
+ );
+ expect(
+ referencingLines.length,
+ `Expected a MEMORY.md pointer for ${sibling} (auto-bundle would also add one).`,
+ ).toBeGreaterThan(0);
+ const allAbsolute = referencingLines.every((line) =>
+ line.includes(absolutePath),
+ );
+ expect(
+ allAbsolute,
+ `Pointer for ${sibling} must use absolute path. Saw: ${referencingLines.join(' | ')}`,
+ ).toBe(true);
+ }
+ },
+ });
+
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'auto-memory-contract',
+ suiteType: 'component-level',
+ name: 'never writes to /GEMINI.md even for team-shared facts',
+ files: WORKSPACE_FILES,
+ timeout: 240000,
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ setup: async (config) => {
+ // Sessions that talk about TEAM CONVENTIONS ā the kind of content that
+ // would be a perfect fit for /GEMINI.md, but the prompt
+ // forbids the extraction agent from touching it.
+ await seedSessions(config, [
+ {
+ sessionId: 'team-convention-pnpm-1',
+ summary: 'Team convention: always use pnpm not npm for installs',
+ timestampOffsetMinutes: 420,
+ userTurns: [
+ 'Important team-wide convention for this repo: always use pnpm for installs, never npm.',
+ 'This is a shared rule across all engineers on the project.',
+ 'It applies to every package install, every clean, every dependency add.',
+ 'The rationale is workspace hoisting; npm would break the monorepo layout.',
+ 'This is a durable team rule, committed to the repo conventions.',
+ 'Future agents working in this repo should ALWAYS use pnpm.',
+ 'It is the standard team practice, no exceptions.',
+ 'Document it as part of the project conventions.',
+ 'Treat it as a hard rule for the team.',
+ 'I want this captured for future sessions.',
+ ],
+ },
+ {
+ sessionId: 'team-convention-pnpm-2',
+ summary: 'Reaffirming the pnpm-only team rule in another session',
+ timestampOffsetMinutes: 360,
+ userTurns: [
+ 'Reminder again: this team uses pnpm exclusively, never npm.',
+ 'Another agent tried npm install and broke the lockfile.',
+ 'The team rule is clear: pnpm only for any install operation.',
+ 'It is part of our shared conventions for this codebase.',
+ 'Make sure future agents follow this team-wide rule.',
+ 'It applies to all engineers, all CI runs, all dev environments.',
+ 'The convention is durable and well-established for this repo.',
+ 'Agents should read this rule from project conventions before installing.',
+ 'No future agent should ever invoke `npm install` in this repo.',
+ 'Always pnpm. Always.',
+ ],
+ },
+ ]);
+ },
+ assert: async (config) => {
+ await startMemoryService(config);
+ const inbox = await snapshotInbox(config);
+ const projectRoot = config.storage.getProjectRoot();
+
+ // No private patch should target /GEMINI.md or any
+ // subdirectory GEMINI.md.
+ const projectRootRegex = new RegExp(
+ `\\+\\+\\+ ${projectRoot.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}.*GEMINI\\.md`,
+ );
+ for (const [name, content] of inbox.privateContents) {
+ expect(
+ projectRootRegex.test(content),
+ `Private patch "${name}" must not target a GEMINI.md under . Content:\n${content}`,
+ ).toBe(false);
+ }
+
+ // Verify on disk: /GEMINI.md was not created or modified
+ // by the extraction agent (snapshot rollback should also enforce this,
+ // but we double-check from the post-run state).
+ const projectGemini = path.join(projectRoot, 'GEMINI.md');
+ const exists = await fsp
+ .access(projectGemini)
+ .then(() => true)
+ .catch(() => false);
+ // The seeded workspace's WORKSPACE_FILES doesn't include GEMINI.md, so
+ // it must NOT exist after the run.
+ expect(
+ exists,
+ `/GEMINI.md (${projectGemini}) must not be created by the extraction agent.`,
+ ).toBe(false);
+ },
+ });
+});
diff --git a/evals/auto_memory_modes.eval.ts b/evals/auto_memory_modes.eval.ts
new file mode 100644
index 0000000000..94f5a06281
--- /dev/null
+++ b/evals/auto_memory_modes.eval.ts
@@ -0,0 +1,447 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { afterEach, beforeEach, describe, expect, vi } from 'vitest';
+import { runEval } from './test-helper.js';
+import { SESSION_FILE_PREFIX } from '../packages/core/src/services/chatRecordingService.js';
+
+const evalState = vi.hoisted(() => ({
+ sessionFilePath: '',
+ debugLines: [] as string[],
+}));
+
+const mocks = vi.hoisted(() => ({
+ localAgentCreate: vi.fn(),
+}));
+
+vi.mock('../packages/core/src/agents/local-executor.js', () => ({
+ LocalAgentExecutor: {
+ create: mocks.localAgentCreate,
+ },
+}));
+
+vi.mock('../packages/core/src/agents/local-executor.ts', () => ({
+ LocalAgentExecutor: {
+ create: mocks.localAgentCreate,
+ },
+}));
+
+vi.mock('../packages/core/src/agents/local-executor', () => ({
+ LocalAgentExecutor: {
+ create: mocks.localAgentCreate,
+ },
+}));
+
+vi.mock('../packages/core/src/services/executionLifecycleService.js', () => ({
+ ExecutionLifecycleService: {
+ createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
+ completeExecution: vi.fn(),
+ },
+}));
+
+vi.mock('../packages/core/src/services/executionLifecycleService.ts', () => ({
+ ExecutionLifecycleService: {
+ createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
+ completeExecution: vi.fn(),
+ },
+}));
+
+vi.mock('../packages/core/src/services/executionLifecycleService', () => ({
+ ExecutionLifecycleService: {
+ createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
+ completeExecution: vi.fn(),
+ },
+}));
+
+vi.mock('../packages/core/src/utils/debugLogger.js', () => ({
+ debugLogger: {
+ debug: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ log: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ warn: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ error: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ },
+}));
+
+vi.mock('../packages/core/src/utils/debugLogger.ts', () => ({
+ debugLogger: {
+ debug: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ log: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ warn: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ error: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ },
+}));
+
+vi.mock('../packages/core/src/utils/debugLogger', () => ({
+ debugLogger: {
+ debug: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ log: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ warn: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ error: (...args: unknown[]) =>
+ evalState.debugLines.push(args.map(String).join(' ')),
+ },
+}));
+
+interface MockMemoryConfig {
+ storage: {
+ getProjectMemoryDir: () => string;
+ getProjectMemoryTempDir: () => string;
+ getProjectSkillsMemoryDir: () => string;
+ getProjectTempDir: () => string;
+ getProjectRoot: () => string;
+ };
+ getTargetDir: () => string;
+ getToolRegistry: () => unknown;
+ getGeminiClient: () => unknown;
+ getSkillManager: () => { getSkills: () => unknown[] };
+ isAutoMemoryEnabled: () => boolean;
+ modelConfigService: {
+ registerRuntimeModelConfig: ReturnType;
+ };
+ sandboxManager: undefined;
+}
+
+interface Fixture {
+ rootDir: string;
+ homeDir: string;
+ targetDir: string;
+ projectTempDir: string;
+ memoryDir: string;
+ skillsDir: string;
+ config: MockMemoryConfig;
+}
+
+interface AutoMemoryRunSnapshot {
+ sessionIds?: string[];
+ memoryCandidatesCreated?: string[];
+ memoryFilesUpdated?: string[];
+ skillsCreated?: string[];
+}
+
+const fixtures: Fixture[] = [];
+
+beforeEach(() => {
+ vi.resetModules();
+ evalState.debugLines = [];
+ evalState.sessionFilePath = '';
+ mocks.localAgentCreate.mockReset();
+ mocks.localAgentCreate.mockImplementation(
+ async (_agent, context, onActivity) => ({
+ run: vi.fn().mockImplementation(async () => {
+ if (evalState.sessionFilePath) {
+ const callId = `read-inbox-routing`;
+ onActivity({
+ isSubagentActivityEvent: true,
+ agentName: 'auto-memory-eval',
+ type: 'TOOL_CALL_START',
+ data: {
+ name: 'read_file',
+ callId,
+ args: { file_path: evalState.sessionFilePath },
+ },
+ });
+ onActivity({
+ isSubagentActivityEvent: true,
+ agentName: 'auto-memory-eval',
+ type: 'TOOL_CALL_END',
+ data: { id: callId, data: { isError: false } },
+ });
+ }
+
+ const config = context.config as MockMemoryConfig;
+ const memoryDir = config.storage.getProjectMemoryTempDir();
+ const inboxDir = path.join(memoryDir, '.inbox');
+
+ const homeDir = process.env['GEMINI_CLI_HOME'] ?? os.homedir();
+ const globalGeminiDir = path.join(homeDir, '.gemini');
+
+ await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
+ await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
+
+ const privateTarget = path.join(memoryDir, 'verify-memory.md');
+ await fs.writeFile(
+ path.join(inboxDir, 'private', 'verify-memory.patch'),
+ [
+ `--- /dev/null`,
+ `+++ ${privateTarget}`,
+ `@@ -0,0 +1,3 @@`,
+ `+# Project Memory Candidate`,
+ `+`,
+ `+Future agents should remember that this project verifies memory changes with \`npm run verify:memory\`.`,
+ ``,
+ ].join('\n'),
+ );
+
+ const globalTarget = path.join(globalGeminiDir, 'GEMINI.md');
+ await fs.writeFile(
+ path.join(inboxDir, 'global', 'reply-style.patch'),
+ [
+ `--- /dev/null`,
+ `+++ ${globalTarget}`,
+ `@@ -0,0 +1,1 @@`,
+ `+User prefers concise Chinese architecture plans.`,
+ ``,
+ ].join('\n'),
+ );
+
+ return {
+ turn_count: 3,
+ duration_ms: 25,
+ terminate_reason: 'GOAL',
+ };
+ }),
+ }),
+ );
+});
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ while (fixtures.length > 0) {
+ const fixture = fixtures.pop();
+ if (fixture) {
+ await fs.rm(fixture.rootDir, { recursive: true, force: true });
+ }
+ }
+});
+
+function autoMemoryEval(name: string, fn: () => Promise): void {
+ runEval(
+ 'USUALLY_PASSES',
+ {
+ suiteName: 'auto-memory-modes',
+ suiteType: 'component-level',
+ name,
+ timeout: 30000,
+ },
+ fn,
+ 40000,
+ );
+}
+
+async function createFixture(): Promise {
+ const rootDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'gemini-auto-memory-eval-'),
+ );
+ const homeDir = path.join(rootDir, 'home');
+ const targetDir = path.join(rootDir, 'workspace');
+ const projectTempDir = path.join(rootDir, 'project-temp');
+ const memoryDir = path.join(projectTempDir, 'memory');
+ const skillsDir = path.join(memoryDir, 'skills');
+
+ await fs.mkdir(homeDir, { recursive: true });
+ await fs.mkdir(targetDir, { recursive: true });
+ await fs.mkdir(path.join(projectTempDir, 'chats'), { recursive: true });
+ vi.stubEnv('GEMINI_CLI_HOME', homeDir);
+
+ const config: MockMemoryConfig = {
+ storage: {
+ getProjectMemoryDir: () => memoryDir,
+ getProjectMemoryTempDir: () => memoryDir,
+ getProjectSkillsMemoryDir: () => skillsDir,
+ getProjectTempDir: () => projectTempDir,
+ getProjectRoot: () => targetDir,
+ },
+ getTargetDir: () => targetDir,
+ getToolRegistry: () => ({}),
+ getGeminiClient: () => ({}),
+ getSkillManager: () => ({ getSkills: () => [] }),
+ isAutoMemoryEnabled: () => true,
+ modelConfigService: {
+ registerRuntimeModelConfig: vi.fn(),
+ },
+ sandboxManager: undefined,
+ };
+
+ const fixture = {
+ rootDir,
+ homeDir,
+ targetDir,
+ projectTempDir,
+ memoryDir,
+ skillsDir,
+ config,
+ };
+ fixtures.push(fixture);
+ return fixture;
+}
+
+async function seedSession(
+ fixture: Fixture,
+ sessionId: string,
+): Promise {
+ const sessionFilePath = path.join(
+ fixture.projectTempDir,
+ 'chats',
+ `${SESSION_FILE_PREFIX}2026-04-20T10-00-${sessionId}.json`,
+ );
+ const oldTimestamp = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
+ const messages = Array.from({ length: 20 }, (_, index) => ({
+ id: `m${index + 1}`,
+ timestamp: oldTimestamp,
+ type: index % 2 === 0 ? 'user' : 'gemini',
+ content: [
+ {
+ text:
+ index % 2 === 0
+ ? 'For this project, durable memory changes are verified with `npm run verify:memory`.'
+ : 'Acknowledged.',
+ },
+ ],
+ }));
+
+ await fs.writeFile(
+ sessionFilePath,
+ [
+ {
+ sessionId,
+ projectHash: 'auto-memory-eval',
+ summary: 'Capture durable auto memory routing behavior',
+ startTime: oldTimestamp,
+ lastUpdated: oldTimestamp,
+ kind: 'main',
+ },
+ ...messages,
+ ]
+ .map((record) => JSON.stringify(record))
+ .join('\n') + '\n',
+ );
+
+ return sessionFilePath;
+}
+
+async function expectSeedSessionEligible(
+ fixture: Fixture,
+ sessionId: string,
+): Promise {
+ const { buildSessionIndex } = await import(
+ '../packages/core/src/services/memoryService.js'
+ );
+ const { newSessionIds } = await buildSessionIndex(
+ path.join(fixture.projectTempDir, 'chats'),
+ { runs: [] },
+ );
+ expect(newSessionIds).toContain(sessionId);
+}
+
+async function readRun(fixture: Fixture): Promise {
+ const statePath = path.join(fixture.memoryDir, '.extraction-state.json');
+ let raw: string;
+ try {
+ raw = await fs.readFile(statePath, 'utf-8');
+ } catch (error) {
+ let memoryEntries = '(memory dir missing)';
+ try {
+ memoryEntries = (await fs.readdir(fixture.memoryDir, { recursive: true }))
+ .map(String)
+ .join('\n');
+ } catch {
+ // Leave default diagnostic.
+ }
+ throw new Error(
+ [
+ `Expected extraction state at ${statePath}.`,
+ `LocalAgentExecutor.create calls: ${mocks.localAgentCreate.mock.calls.length}`,
+ `Memory dir entries:\n${memoryEntries}`,
+ `Debug log:\n${evalState.debugLines.join('\n')}`,
+ ].join('\n'),
+ { cause: error },
+ );
+ }
+ const state = JSON.parse(raw) as {
+ runs?: AutoMemoryRunSnapshot[];
+ };
+ const run = state.runs?.at(-1);
+ if (!run) {
+ throw new Error('Expected an auto memory extraction run to be recorded');
+ }
+ return run;
+}
+
+async function fileExists(filePath: string): Promise {
+ try {
+ await fs.access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+describe('Auto Memory inbox routing', () => {
+ autoMemoryEval(
+ 'every memory patch lands in .inbox// for review and active files stay untouched',
+ async () => {
+ const { startMemoryService } = await import(
+ '../packages/core/src/services/memoryService.js'
+ );
+ const fixture = await createFixture();
+ evalState.sessionFilePath = await seedSession(
+ fixture,
+ 'inbox-routing-session',
+ );
+ await expectSeedSessionEligible(fixture, 'inbox-routing-session');
+
+ await startMemoryService(fixture.config as never);
+
+ const privatePatchPath = path.join(
+ fixture.memoryDir,
+ '.inbox',
+ 'private',
+ 'verify-memory.patch',
+ );
+ const globalPatchPath = path.join(
+ fixture.memoryDir,
+ '.inbox',
+ 'global',
+ 'reply-style.patch',
+ );
+
+ const activePrivateMemoryPath = path.join(
+ fixture.memoryDir,
+ 'verify-memory.md',
+ );
+ const activeGlobalMemoryPath = path.join(
+ fixture.homeDir,
+ '.gemini',
+ 'GEMINI.md',
+ );
+ const run = await readRun(fixture);
+
+ // Both patches were written to the inbox.
+ await expect(fs.readFile(privatePatchPath, 'utf-8')).resolves.toContain(
+ 'npm run verify:memory',
+ );
+ await expect(fs.readFile(globalPatchPath, 'utf-8')).resolves.toContain(
+ 'concise Chinese architecture plans',
+ );
+
+ // No active file was touched ā every patch must be reviewed manually.
+ expect(await fileExists(activePrivateMemoryPath)).toBe(false);
+ expect(await fileExists(activeGlobalMemoryPath)).toBe(false);
+
+ // Run state records both patches as candidates and zero applied files.
+ expect(run.memoryFilesUpdated ?? []).toEqual([]);
+ expect(run.memoryCandidatesCreated ?? []).toEqual(
+ expect.arrayContaining([
+ path.relative(fixture.memoryDir, privatePatchPath),
+ path.relative(fixture.memoryDir, globalPatchPath),
+ ]),
+ );
+ },
+ );
+});
diff --git a/evals/file_creation_behavior.eval.ts b/evals/file_creation_behavior.eval.ts
new file mode 100644
index 0000000000..2092eadb5b
--- /dev/null
+++ b/evals/file_creation_behavior.eval.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect } from 'vitest';
+import { evalTest } from './test-helper.js';
+
+describe('file_creation_behavior', () => {
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: 'should create a new file in the correct directory when asked',
+ files: {
+ 'package.json': JSON.stringify({
+ name: 'test-project',
+ version: '1.0.0',
+ type: 'module',
+ }),
+ 'src/index.ts': 'console.log("hello");',
+ },
+ prompt:
+ 'Please create a new file called src/logger.ts containing a simple logging class. Do not modify any existing files.',
+ assert: async (rig) => {
+ // 1) Verify write_file tool was called
+ const logs = rig.readToolLogs();
+ const writeFileCalls = logs.filter(
+ (log) => log.toolRequest?.name === 'write_file',
+ );
+ expect(
+ writeFileCalls.length,
+ 'Expected a write_file call to create the new file',
+ ).toBeGreaterThanOrEqual(1);
+
+ // 2) Verify existing files were not modified
+ const indexContent = rig.readFile('src/index.ts');
+ expect(indexContent).toBe('console.log("hello");');
+
+ const pkgContent = rig.readFile('package.json');
+ expect(JSON.parse(pkgContent).name).toBe('test-project');
+
+ // 3) Verify new file is created
+ const loggerContent = rig.readFile('src/logger.ts');
+ expect(loggerContent.length).toBeGreaterThan(0);
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: 'should not overwrite existing file when creating new file with same name',
+ files: {
+ 'package.json': JSON.stringify({
+ name: 'test-project',
+ version: '1.0.0',
+ type: 'module',
+ }),
+ 'config.json': JSON.stringify({ port: 3000, env: 'production' }),
+ },
+ prompt:
+ "Please create a new configuration file called config.json in the workspace. Ensure the port is set to 8080. Since there's already a config file there, make sure to check it first before making changes.",
+ assert: async (rig) => {
+ // Verify that read_file was called on config.json before write_file
+ const logs = rig.readToolLogs();
+ const targetReadFileIndex = logs.findIndex((log) => {
+ if (log.toolRequest?.name !== 'read_file') return false;
+ try {
+ const args =
+ typeof log.toolRequest.args === 'string'
+ ? JSON.parse(log.toolRequest.args)
+ : log.toolRequest.args;
+ return args.file_path === 'config.json';
+ } catch {
+ return false;
+ }
+ });
+
+ const targetWriteFileIndex = logs.findIndex((log) => {
+ if (log.toolRequest?.name !== 'write_file') return false;
+ try {
+ const args =
+ typeof log.toolRequest.args === 'string'
+ ? JSON.parse(log.toolRequest.args)
+ : log.toolRequest.args;
+ return args.file_path === 'config.json';
+ } catch {
+ return false;
+ }
+ });
+
+ expect(
+ targetReadFileIndex,
+ 'Expected read_file to be called to inspect config.json before overwriting it',
+ ).toBeGreaterThanOrEqual(0);
+
+ if (targetWriteFileIndex !== -1) {
+ expect(
+ targetReadFileIndex,
+ 'Expected read_file to be invoked before write_file for safety',
+ ).toBeLessThan(targetWriteFileIndex);
+ }
+
+ // Also check the resulting config.json content
+ const configContent = rig.readFile('config.json');
+ expect(configContent).toContain('8080');
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: 'should scaffold multiple related files in correct locations',
+ files: {
+ 'package.json': JSON.stringify({
+ name: 'test-project',
+ version: '1.0.0',
+ type: 'module',
+ }),
+ },
+ prompt:
+ 'Please scaffold auth validation and types by creating two new files: src/auth/validator.ts and src/auth/types.ts with relevant exports. Do not modify existing files.',
+ assert: async (rig) => {
+ // Verify files are created in right place
+ const validatorContent = rig.readFile('src/auth/validator.ts');
+ const typesContent = rig.readFile('src/auth/types.ts');
+
+ expect(validatorContent.length).toBeGreaterThan(0);
+ expect(typesContent.length).toBeGreaterThan(0);
+ },
+ });
+});
diff --git a/evals/gitRepo.eval.ts b/evals/gitRepo.eval.ts
index b5dbd8a760..1f69ba7560 100644
--- a/evals/gitRepo.eval.ts
+++ b/evals/gitRepo.eval.ts
@@ -78,4 +78,37 @@ describe('git repo eval', () => {
expect(commitCalls.length).toBeGreaterThanOrEqual(1);
},
});
+
+ /**
+ * Ensures that when the agent is prompted to commit its changes, it does not
+ * use `git add .` or `git add -A`.
+ */
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: 'should not stage changes via git add . when prompted to commit',
+ prompt:
+ 'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, stage and commit your changes.',
+ files: FILES,
+ assert: async (rig, _result) => {
+ const toolLogs = rig.readToolLogs();
+ const gitAddAllCalls = toolLogs.filter((log) => {
+ if (log.toolRequest.name !== 'run_shell_command') return false;
+ try {
+ const args = JSON.parse(log.toolRequest.args);
+ if (!args.command) return false;
+ const cmd = args.command.toLowerCase();
+ return (
+ cmd.includes('git add .') ||
+ cmd.includes('git add -a') ||
+ cmd.includes('git add --all')
+ );
+ } catch {
+ return false;
+ }
+ });
+
+ expect(gitAddAllCalls.length).toBe(0);
+ },
+ });
});
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
index ade92995e1..f13d4707b0 100644
--- a/packages/cli/index.ts
+++ b/packages/cli/index.ts
@@ -75,11 +75,7 @@ async function getMemoryNodeArgs(): Promise {
}
async function run() {
- if (
- !process.env['GEMINI_CLI_NO_RELAUNCH'] &&
- !process.env['SANDBOX'] &&
- process.env['IS_BINARY'] !== 'true'
- ) {
+ if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
// --- Lightweight Parent Process / Daemon ---
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
diff --git a/packages/cli/src/acp/acpRpcDispatcher.ts b/packages/cli/src/acp/acpRpcDispatcher.ts
index 97fb0d4011..a7d7d26e61 100644
--- a/packages/cli/src/acp/acpRpcDispatcher.ts
+++ b/packages/cli/src/acp/acpRpcDispatcher.ts
@@ -33,6 +33,10 @@ export class GeminiAgent {
this.sessionManager = new AcpSessionManager(settings, argv, connection);
}
+ dispose(): void {
+ this.sessionManager.dispose();
+ }
+
async initialize(
args: acp.InitializeRequest,
): Promise {
diff --git a/packages/cli/src/acp/acpSession.test.ts b/packages/cli/src/acp/acpSession.test.ts
index c87c1cc4b4..14f04ba7c5 100644
--- a/packages/cli/src/acp/acpSession.test.ts
+++ b/packages/cli/src/acp/acpSession.test.ts
@@ -564,4 +564,26 @@ describe('Session', () => {
expect(result.stopReason).toBe('max_turn_requests');
});
+
+ it('should send sessionUpdate when approval mode changes', async () => {
+ const { coreEvents, CoreEvent, ApprovalMode } = await import(
+ '@google/gemini-cli-core'
+ );
+
+ coreEvents.emit(CoreEvent.ApprovalModeChanged, {
+ sessionId: 'session-1',
+ mode: ApprovalMode.PLAN,
+ });
+
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
+ sessionId: 'session-1',
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: `[MODE_UPDATE] ${ApprovalMode.PLAN}`,
+ },
+ },
+ });
+ });
});
diff --git a/packages/cli/src/acp/acpSession.ts b/packages/cli/src/acp/acpSession.ts
index bcc8a86248..da7401cba1 100644
--- a/packages/cli/src/acp/acpSession.ts
+++ b/packages/cli/src/acp/acpSession.ts
@@ -8,6 +8,9 @@ import {
type ApprovalMode,
type ConversationRecord,
CoreToolCallStatus,
+ coreEvents,
+ CoreEvent,
+ type ApprovalModeChangedPayload,
logToolCall,
convertToFunctionResponse,
ToolConfirmationOutcome,
@@ -69,7 +72,31 @@ export class Session {
private readonly context: AgentLoopContext,
private readonly connection: acp.AgentSideConnection,
private readonly settings: LoadedSettings,
- ) {}
+ ) {
+ coreEvents.on(
+ CoreEvent.ApprovalModeChanged,
+ this.handleApprovalModeChanged,
+ );
+ }
+
+ private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
+ if (payload.sessionId === this.id) {
+ void this.sendUpdate({
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: `[MODE_UPDATE] ${payload.mode}`,
+ },
+ });
+ }
+ };
+
+ dispose(): void {
+ coreEvents.off(
+ CoreEvent.ApprovalModeChanged,
+ this.handleApprovalModeChanged,
+ );
+ }
async cancelPendingPrompt(): Promise {
if (!this.pendingPrompt) {
diff --git a/packages/cli/src/acp/acpSessionManager.ts b/packages/cli/src/acp/acpSessionManager.ts
index 828dae9b14..2109257317 100644
--- a/packages/cli/src/acp/acpSessionManager.ts
+++ b/packages/cli/src/acp/acpSessionManager.ts
@@ -48,6 +48,13 @@ export class AcpSessionManager {
return this.sessions.get(sessionId);
}
+ dispose(): void {
+ for (const session of this.sessions.values()) {
+ session.dispose();
+ }
+ this.sessions.clear();
+ }
+
async newSession(
{ cwd, mcpServers }: acp.NewSessionRequest,
authDetails: AuthDetails,
@@ -183,6 +190,12 @@ export class AcpSessionManager {
this.connection,
this.settings,
);
+
+ const existingSession = this.sessions.get(sessionId);
+ if (existingSession) {
+ existingSession.dispose();
+ }
+
this.sessions.set(sessionId, session);
// Stream history back to client
diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts
index bb91e5dbdd..96f105e3cf 100644
--- a/packages/cli/src/acp/commands/memory.ts
+++ b/packages/cli/src/acp/commands/memory.ts
@@ -6,6 +6,7 @@
import {
addMemory,
+ listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
- 'Lists skills extracted from past sessions that are pending review.';
+ 'Lists memory items extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
};
}
- const [skills, patches] = await Promise.all([
+ const [skills, patches, memoryPatches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
+ listInboxMemoryPatches(context.agentContext.config),
]);
- if (skills.length === 0 && patches.length === 0) {
+ if (
+ skills.length === 0 &&
+ patches.length === 0 &&
+ memoryPatches.length === 0
+ ) {
return { name: this.name, data: 'No items in inbox.' };
}
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
+ for (const memoryPatch of memoryPatches) {
+ const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
+ const date = memoryPatch.extractedAt
+ ? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
+ : '';
+ const sourceCount = memoryPatch.sourceFiles.length;
+ const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
+ lines.push(
+ `- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
+ );
+ }
- const total = skills.length + patches.length;
+ const total = skills.length + patches.length + memoryPatches.length;
return {
name: this.name,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
diff --git a/packages/cli/src/commands/extensions/configure.test.ts b/packages/cli/src/commands/extensions/configure.test.ts
index cf86d6cc71..dffd3fee37 100644
--- a/packages/cli/src/commands/extensions/configure.test.ts
+++ b/packages/cli/src/commands/extensions/configure.test.ts
@@ -20,8 +20,11 @@ import {
getScopedEnvContents,
type ExtensionSetting,
} from '../../config/extensions/extensionSettings.js';
+import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import prompts from 'prompts';
import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
vi.hoisted(() => {
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
vi.spyOn(debugLogger, 'error');
vi.clearAllMocks();
- tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
+ tempWorkspaceDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
+ );
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
);
});
- afterEach(() => {
+ afterEach(async () => {
+ await cleanupTmpDir(tempWorkspaceDir);
vi.restoreAllMocks();
});
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 312517db56..9cb48dfc7b 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -1174,6 +1174,20 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
['.git'], // boundaryMarkers
);
});
+
+ it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
+ process.argv = ['node', 'script.js'];
+ const settings = createTestMergedSettings({
+ experimental: { jitContext: false },
+ });
+
+ const argv = await parseArguments(settings);
+ await loadCliConfig(settings, 'session-id', argv, {
+ skipMemoryLoad: true,
+ });
+
+ expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
+ });
});
describe('mergeMcpServers', () => {
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 97689b5fe5..389fc4d2a7 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -560,6 +560,7 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
+ skipMemoryLoad?: boolean;
}
export async function loadCliConfig(
@@ -568,7 +569,12 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise {
- const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
+ const {
+ cwd = process.cwd(),
+ projectHooks,
+ skipExtensions = false,
+ skipMemoryLoad = false,
+ } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -681,7 +687,7 @@ export async function loadCliConfig(
const finalExtensionLoader =
extensionManager ?? new SimpleExtensionLoader([]);
- if (!experimentalJitContext) {
+ if (!experimentalJitContext && !skipMemoryLoad) {
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
cwd,
diff --git a/packages/cli/src/config/extension-manager-scope.test.ts b/packages/cli/src/config/extension-manager-scope.test.ts
index f88673e692..5e93face28 100644
--- a/packages/cli/src/config/extension-manager-scope.test.ts
+++ b/packages/cli/src/config/extension-manager-scope.test.ts
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings } from './settings.js';
+import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import {
loadAgentsFromDirectory,
loadSkillsFromDir,
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
);
});
- afterEach(() => {
- // Clean up files if needed, or rely on temp dir cleanup
+ afterEach(async () => {
+ await cleanupTmpDir(currentTempHome);
+ await cleanupTmpDir(tempWorkspace);
vi.clearAllMocks();
});
diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts
index 8de884cdd5..9bde2705bf 100644
--- a/packages/cli/src/config/extensions/consent.test.ts
+++ b/packages/cli/src/config/extensions/consent.test.ts
@@ -149,6 +149,35 @@ describe('consent', () => {
expect(consent).toBe(expected);
},
);
+
+ it('should clear the active confirmation request before resolving', async () => {
+ const clearConfirmationRequest = vi.fn();
+ const steps: string[] = [];
+ const addExtensionUpdateConfirmationRequest = vi
+ .fn()
+ .mockImplementation((request: ConfirmationRequest) => {
+ steps.push('prompted');
+ request.onConfirm(true);
+ steps.push('confirmed');
+ });
+
+ const consentPromise = requestConsentInteractive(
+ 'Test consent',
+ addExtensionUpdateConfirmationRequest,
+ () => {
+ steps.push('cleared');
+ clearConfirmationRequest();
+ },
+ ).then((consent) => {
+ steps.push('resolved');
+ return consent;
+ });
+
+ expect(clearConfirmationRequest).toHaveBeenCalledTimes(1);
+ expect(steps).toEqual(['prompted', 'cleared', 'confirmed']);
+ await expect(consentPromise).resolves.toBe(true);
+ expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']);
+ });
});
describe('maybeRequestConsentOrFail', () => {
diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts
index 5c35c0d899..b39609b961 100644
--- a/packages/cli/src/config/extensions/consent.ts
+++ b/packages/cli/src/config/extensions/consent.ts
@@ -78,10 +78,12 @@ export async function requestConsentNonInteractive(
export async function requestConsentInteractive(
consentDescription: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
+ clearConfirmationRequest?: () => void,
): Promise {
return promptForConsentInteractive(
consentDescription + '\n\nDo you want to continue?',
addExtensionUpdateConfirmationRequest,
+ clearConfirmationRequest,
);
}
@@ -129,12 +131,14 @@ export async function promptForConsentNonInteractive(
async function promptForConsentInteractive(
prompt: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
+ clearConfirmationRequest?: () => void,
): Promise {
return new Promise((resolve) => {
addExtensionUpdateConfirmationRequest({
prompt,
onConfirm: (resolvedConfirmed) => {
- resolve(resolvedConfirmed);
+ clearConfirmationRequest?.();
+ setImmediate(() => resolve(resolvedConfirmed));
},
});
});
diff --git a/packages/cli/src/config/settings-env-isolation.test.ts b/packages/cli/src/config/settings-env-isolation.test.ts
new file mode 100644
index 0000000000..526b85ef85
--- /dev/null
+++ b/packages/cli/src/config/settings-env-isolation.test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import type * as osActual from 'node:os';
+
+vi.mock('node:os', async (importOriginal) => {
+ const actualOs = await importOriginal();
+ return {
+ ...actualOs,
+ homedir: vi.fn(() => path.resolve('/mock/home')),
+ platform: vi.fn(() => 'linux'),
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ homedir: vi.fn(() => path.resolve('/mock/home')),
+ };
+});
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import { loadEnvironment, type Settings } from './settings.js';
+import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
+
+vi.mock('node:fs');
+
+describe('Environment Isolation', () => {
+ const mockHome = path.resolve('/mock/home');
+ const mockWorkspace = path.resolve('/mock/workspace');
+ const originalArgv = process.argv;
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(os.homedir).mockReturnValue(mockHome);
+ vi.mocked(coreHomedir).mockReturnValue(mockHome);
+ // Default to no files existing
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+ process.argv = ['node', 'gemini'];
+
+ // Clear env vars that might leak from the host environment
+ delete process.env['GEMINI_API_KEY'];
+ delete process.env['OTHER_VAR'];
+ });
+
+ afterEach(() => {
+ process.argv = originalArgv;
+ process.env = { ...originalEnv };
+ });
+
+ it('should load local .env by default', () => {
+ const workspaceEnv = path.join(mockWorkspace, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === workspaceEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
+
+ const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBe('local');
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should ignore local .env when ignoreLocalEnv is true', () => {
+ const workspaceEnv = path.join(mockWorkspace, '.env');
+ const homeEnv = path.join(mockHome, '.env');
+
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
+ const ps = p.toString();
+ return ps === workspaceEnv || ps === homeEnv;
+ });
+ vi.mocked(fs.readFileSync).mockImplementation((p) => {
+ const ps = p.toString();
+ if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
+ if (ps === homeEnv) return 'GEMINI_API_KEY=home';
+ return '';
+ });
+
+ const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ // Should skip local and find home
+ expect(process.env['GEMINI_API_KEY']).toBe('home');
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
+ const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === workspaceGeminiEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
+
+ const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should respect --ignore-env flag', () => {
+ const workspaceEnv = path.join(mockWorkspace, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === workspaceEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
+
+ process.argv = ['node', 'gemini', '--ignore-env'];
+ const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBeUndefined();
+ });
+
+ it('should allow home .env even with ignoreLocalEnv true', () => {
+ const homeEnv = path.join(mockHome, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === homeEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
+
+ const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
+ // Running from home dir
+ loadEnvironment(settings, mockHome, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBe('home');
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
+ const deepProject = path.join(mockWorkspace, 'deep', 'dir');
+ const deepEnv = path.join(deepProject, '.env');
+ const parentEnv = path.join(mockWorkspace, '.env');
+ const homeEnv = path.join(mockHome, '.env');
+
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
+ const ps = p.toString();
+ return ps === deepEnv || ps === parentEnv || ps === homeEnv;
+ });
+ vi.mocked(fs.readFileSync).mockImplementation((p) => {
+ const ps = p.toString();
+ if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
+ if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
+ if (ps === homeEnv) return 'GEMINI_API_KEY=home';
+ return '';
+ });
+
+ const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
+ loadEnvironment(settings, deepProject, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBe('home');
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should respect trust whitelist even when loading from home .env', () => {
+ const homeEnv = path.join(mockHome, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === homeEnv,
+ );
+ // Include one whitelisted and one non-whitelisted variable
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ 'GEMINI_API_KEY=home\nOTHER_VAR=secret',
+ );
+
+ const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
+ // Running from an UNTRUSTED workspace
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: false,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBe('home');
+ expect(process.env['OTHER_VAR']).toBeUndefined();
+ delete process.env['GEMINI_API_KEY'];
+ });
+
+ it('should prioritize --ignore-env flag even if setting is false', () => {
+ const workspaceEnv = path.join(mockWorkspace, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === workspaceEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
+
+ process.argv = ['node', 'gemini', '--ignore-env'];
+ const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBeUndefined();
+ });
+
+ it('should respect both -s and --ignore-env flags simultaneously', () => {
+ const workspaceEnv = path.join(mockWorkspace, '.env');
+ vi.mocked(fs.existsSync).mockImplementation(
+ (p) => p.toString() === workspaceEnv,
+ );
+ vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
+
+ process.argv = ['node', 'gemini', '-s', '--ignore-env'];
+ const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
+ loadEnvironment(settings, mockWorkspace, () => ({
+ isTrusted: true,
+ source: 'file',
+ }));
+
+ expect(process.env['GEMINI_API_KEY']).toBeUndefined();
+ });
+});
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 809b8f48ff..eb7e991e6b 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -3294,6 +3294,32 @@ MALICIOUS_VAR=allowed-because-trusted
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-vertex-project');
});
+ it('should respect .env override for GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
+ process.argv = ['node', 'gemini', '-s', 'prompt'];
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: true,
+ source: 'file',
+ });
+
+ // Mock .env file to override the shell project
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ 'GOOGLE_CLOUD_PROJECT=env-vertex-project',
+ );
+
+ loadEnvironment(
+ createMockSettings({
+ tools: { sandbox: false },
+ security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
+ }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('env-vertex-project');
+ });
+
it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 2d94e719b2..cd6b3c61cb 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -500,7 +500,11 @@ export class LoadedSettings {
}
}
-function findEnvFile(startDir: string, isTrusted: boolean): string | null {
+function findEnvFile(
+ startDir: string,
+ isTrusted: boolean,
+ ignoreLocalEnv: boolean,
+): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
@@ -512,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
- return envPath;
+ if (!ignoreLocalEnv || currentDir === homedir()) {
+ return envPath;
+ }
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
@@ -553,15 +559,6 @@ export function setUpCloudShellEnvironment(
// However, if the user has explicitly selected Vertex AI auth, they intend
// to use their own GCP project, so we restore the original value and skip
// the Cloud Shell override to respect their .env settings.
- if (selectedAuthType === AuthType.USE_VERTEX_AI) {
- const saved = process.env[USER_GCP_PROJECT];
- if (saved !== undefined) {
- process.env['GOOGLE_CLOUD_PROJECT'] = saved;
- } else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') {
- delete process.env['GOOGLE_CLOUD_PROJECT'];
- }
- return;
- }
// Save the user's original value before overwriting, so it can be restored
// if the user later switches to Vertex AI (even after a process restart).
@@ -572,7 +569,11 @@ export function setUpCloudShellEnvironment(
}
}
- let value = 'cloudshell-gca';
+ let value: string | undefined = 'cloudshell-gca';
+
+ if (selectedAuthType === AuthType.USE_VERTEX_AI) {
+ value = process.env[USER_GCP_PROJECT];
+ }
if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
@@ -585,7 +586,12 @@ export function setUpCloudShellEnvironment(
}
}
}
- process.env['GOOGLE_CLOUD_PROJECT'] = value;
+
+ if (value !== undefined) {
+ process.env['GOOGLE_CLOUD_PROJECT'] = value;
+ } else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') {
+ delete process.env['GOOGLE_CLOUD_PROJECT'];
+ }
}
export function loadEnvironment(
@@ -595,7 +601,6 @@ export function loadEnvironment(
): void {
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
const isTrusted = trustResult.isTrusted ?? false;
- const envFilePath = findEnvFile(workspaceDir, isTrusted);
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
@@ -612,6 +617,12 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
+ const shouldIgnoreEnv =
+ !!settings.advanced?.ignoreLocalEnv ||
+ relevantArgs.includes('--ignore-env');
+
+ const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
+
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
const selectedAuthType = settings.security?.auth?.selectedType;
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 5df30a20a5..d27457bcd6 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
+ ignoreLocalEnv: {
+ type: 'boolean',
+ label: 'Ignore Local .env',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Whether to ignore generic .env files in the project directory.',
+ showInDialog: true,
+ },
bugCommand: {
type: 'object',
label: 'Bug Command',
@@ -2057,8 +2067,8 @@ const SETTINGS_SCHEMA = {
label: 'Gemma Models',
category: 'Experimental',
requiresRestart: true,
- default: false,
- description: 'Enable access to Gemma 4 models (experimental).',
+ default: true,
+ description: 'Enable access to Gemma 4 models via Gemini API.',
showInDialog: true,
},
voiceMode: {
@@ -2099,7 +2109,11 @@ const SETTINGS_SCHEMA = {
category: 'Experimental',
requiresRestart: false,
default: 'gemini-live',
- description: 'The backend to use for voice transcription.',
+ description: oneLine`
+ The backend to use for voice transcription. Note: When using the
+ Gemini Live backend, voice recordings are sent to Google Cloud for
+ transcription.
+ `,
showInDialog: true,
options: [
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
@@ -2406,7 +2420,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
- 'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
+ 'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `/.inbox//` and held for review in /memory inbox; nothing is applied until you approve it.',
showInDialog: true,
},
generalistProfile: {
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index b9cda80d8b..892ee9862a 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -126,11 +126,7 @@ export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
);
}
- if (
- process.env['IS_BINARY'] === 'true' ||
- process.env['GEMINI_CLI_NO_RELAUNCH'] ||
- process.env['SANDBOX']
- ) {
+ if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
return [];
}
@@ -416,6 +412,7 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
+ skipMemoryLoad: true,
});
adminControlsListner.setConfig(partialConfig);
@@ -833,7 +830,7 @@ export function initializeOutputListenersAndFlush(config?: Config) {
}
const outputFormat = config?.getOutputFormat();
- const forceToStderr = outputFormat === 'json' || config === undefined;
+ const forceToStderr = outputFormat === 'json';
coreEvents.drainBacklogs(
(event: K, args: CoreEvents[K]) => {
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index 8547e150ef..4cfb6423bb 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -263,7 +263,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
undefined,
- false,
'Test input',
);
expect(getWrittenOutput()).toBe('Hello World\n');
@@ -382,7 +381,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-2',
undefined,
- false,
undefined,
);
expect(getWrittenOutput()).toBe('Final answer\n');
@@ -542,7 +540,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-3',
undefined,
- false,
undefined,
);
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
@@ -684,7 +681,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-7',
undefined,
- false,
rawInput,
);
@@ -720,7 +716,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
undefined,
- false,
'Test input',
);
expect(processStdoutSpy).toHaveBeenCalledWith(
@@ -853,7 +848,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-empty',
undefined,
- false,
'Empty response test',
);
@@ -990,7 +984,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
- false,
'/testcommand',
);
@@ -1036,7 +1029,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
- false,
'/help',
);
expect(getWrittenOutput()).toBe('Response to slash command\n');
@@ -1214,7 +1206,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-unknown',
undefined,
- false,
'/unknowncommand',
);
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index 04149a8b28..47de5d9846 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -319,7 +319,6 @@ export async function runNonInteractive(
abortController.signal,
prompt_id,
undefined,
- false,
turnCount === 1 ? input : undefined,
);
diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts
index 5d3957421a..1ae71b282f 100644
--- a/packages/cli/src/nonInteractiveCliAgentSession.test.ts
+++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts
@@ -269,7 +269,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
undefined,
- false,
'Test input',
);
expect(getWrittenOutput()).toBe('Hello World\n');
@@ -436,7 +435,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-2',
undefined,
- false,
undefined,
);
expect(getWrittenOutput()).toBe('Final answer\n');
@@ -596,7 +594,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-3',
undefined,
- false,
undefined,
);
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
@@ -738,7 +735,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-7',
undefined,
- false,
rawInput,
);
@@ -774,7 +770,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
undefined,
- false,
'Test input',
);
expect(processStdoutSpy).toHaveBeenCalledWith(
@@ -980,7 +975,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-empty',
undefined,
- false,
'Empty response test',
);
@@ -1117,7 +1111,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
- false,
'/testcommand',
);
@@ -1163,7 +1156,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-slash',
undefined,
- false,
'/help',
);
expect(getWrittenOutput()).toBe('Response to slash command\n');
@@ -1383,7 +1375,6 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-unknown',
undefined,
- false,
'/unknowncommand',
);
diff --git a/packages/cli/src/output-redirection.test.ts b/packages/cli/src/output-redirection.test.ts
index 2dc935b330..8baa1127f5 100644
--- a/packages/cli/src/output-redirection.test.ts
+++ b/packages/cli/src/output-redirection.test.ts
@@ -69,16 +69,16 @@ describe('Output Redirection', () => {
expect(writeToStderr).not.toHaveBeenCalled();
});
- it('should force stdout to stderr when config is undefined (early failure)', () => {
+ it('should NOT force stdout to stderr when config is undefined (early init/version)', () => {
// Simulate buffered output during early init
coreEvents.emitOutput(false, 'early init message');
// Initialize with undefined config
initializeOutputListenersAndFlush(undefined);
- // Verify it was forced to stderr
- expect(writeToStderr).toHaveBeenCalledWith('early init message', undefined);
- expect(writeToStdout).not.toHaveBeenCalled();
+ // Verify it went to stdout (default behavior)
+ expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined);
+ expect(writeToStderr).not.toHaveBeenCalled();
});
it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
index d53273134c..aca91ab9d8 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.test.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
agentsCommand: { name: 'agents' },
}));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
+vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
+ bugMemoryCommand: { name: 'bug-memory' },
+}));
vi.mock('../ui/commands/chatCommand.js', () => ({
chatCommand: {
name: 'chat',
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 1c5288707c..5312d834e4 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
+import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { commandsCommand } from '../ui/commands/commandsCommand.js';
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
+ bugMemoryCommand,
{
...chatCommand,
subCommands: chatResumeSubCommands,
diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts
index 43ee0f773c..61051ac935 100644
--- a/packages/cli/src/test-utils/mockConfig.ts
+++ b/packages/cli/src/test-utils/mockConfig.ts
@@ -135,7 +135,6 @@ export const createMockConfig = (overrides: Partial = {}): Config =>
getUseRipgrep: vi.fn().mockReturnValue(false),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
- getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index a09f477045..d8b1e1d277 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1127,18 +1127,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [config, historyManager]);
- const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>(
- () => {},
- );
+ const cancelHandlerRef = useRef<
+ (shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void
+ >(() => {});
- const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => {
- if (shouldRestorePrompt) {
- setPendingRestorePrompt(true);
- } else {
- setPendingRestorePrompt(false);
- cancelHandlerRef.current(false);
- }
- }, []);
+ const onCancelSubmit = useCallback(
+ (shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
+ if (shouldRestorePrompt) {
+ setPendingRestorePrompt(true);
+ } else {
+ setPendingRestorePrompt(false);
+ cancelHandlerRef.current(false, clearBuffer);
+ }
+ },
+ [],
+ );
useEffect(() => {
if (pendingRestorePrompt) {
@@ -1321,18 +1324,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
cancelHandlerRef.current = useCallback(
- (shouldRestorePrompt: boolean = true) => {
- if (isToolAwaitingConfirmation(pendingHistoryItems)) {
+ (shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => {
+ if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) {
return; // Don't clear - user may be composing a follow-up message
}
- if (isToolExecuting(pendingHistoryItems)) {
- buffer.setText(''); // Clear for Ctrl+C cancellation
- return;
- }
- // If cancelling (shouldRestorePrompt=false), never modify the buffer
- // User is in control - preserve whatever text they typed, pasted, or restored
+ // If cancelling (shouldRestorePrompt=false):
if (!shouldRestorePrompt) {
+ // Clear the buffer if explicitly requested (e.g., Ctrl+C)
+ if (clearBuffer) {
+ buffer.setText('');
+ }
+ // Otherwise (e.g., Escape), user is in control - preserve whatever text they typed
return;
}
diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts
index 1a5de99122..68874ffbe8 100644
--- a/packages/cli/src/ui/commands/agentsCommand.test.ts
+++ b/packages/cli/src/ui/commands/agentsCommand.test.ts
@@ -110,7 +110,15 @@ describe('agentsCommand', () => {
});
it('should reload the agent registry when reload subcommand is called', async () => {
- const reloadSpy = vi.fn().mockResolvedValue(undefined);
+ const reloadSpy = vi.fn().mockResolvedValue({
+ totalLoaded: 3,
+ localCount: 2,
+ remoteCount: 1,
+ newAgents: ['new-agent'],
+ updatedAgents: ['updated-agent'],
+ deletedAgents: ['deleted-agent'],
+ errors: [],
+ });
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
@@ -120,7 +128,10 @@ describe('agentsCommand', () => {
);
expect(reloadCommand).toBeDefined();
- const result = await reloadCommand!.action!(mockContext, '');
+ const result = (await reloadCommand!.action!(mockContext, '')) as {
+ type: 'message';
+ content: string;
+ };
expect(reloadSpy).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -132,8 +143,42 @@ describe('agentsCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
- content: 'Agents reloaded successfully',
+ content: expect.stringContaining('Agents reloaded successfully:'),
});
+ expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
+ expect(result.content).toContain('- New: new-agent');
+ expect(result.content).toContain('- Updated: updated-agent');
+ expect(result.content).toContain('- Deleted: deleted-agent');
+ expect(result.content).toContain(
+ 'Run /agents list to see all available agents.',
+ );
+ });
+
+ it('should show "reloaded with errors" if errors occurred during reload', async () => {
+ const reloadSpy = vi.fn().mockResolvedValue({
+ totalLoaded: 1,
+ localCount: 1,
+ remoteCount: 0,
+ newAgents: [],
+ updatedAgents: [],
+ deletedAgents: [],
+ errors: ['Some error'],
+ });
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
+ reload: reloadSpy,
+ });
+
+ const reloadCommand = agentsCommand.subCommands?.find(
+ (cmd) => cmd.name === 'reload',
+ );
+
+ const result = (await reloadCommand!.action!(mockContext, '')) as {
+ type: 'message';
+ content: string;
+ };
+
+ expect(result.content).toContain('Agents reloaded with errors:');
+ expect(result.content).toContain('- Errors: 1 encountered during reload');
});
it('should show an error if agent registry is not available during reload', async () => {
diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts
index d1b582d673..4af6564979 100644
--- a/packages/cli/src/ui/commands/agentsCommand.ts
+++ b/packages/cli/src/ui/commands/agentsCommand.ts
@@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = {
text: 'Reloading agent registry...',
});
- await agentRegistry.reload();
+ const summary = await agentRegistry.reload();
+
+ let content =
+ summary.errors.length > 0
+ ? 'Agents reloaded with errors:'
+ : 'Agents reloaded successfully:';
+ content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
+
+ if (summary.newAgents.length > 0) {
+ content += `\n- New: ${summary.newAgents.join(', ')}`;
+ }
+ if (summary.updatedAgents.length > 0) {
+ content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
+ }
+ if (summary.deletedAgents.length > 0) {
+ content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
+ }
+ if (summary.errors.length > 0) {
+ content += `\n- Errors: ${summary.errors.length} encountered during reload`;
+ }
+
+ content += '\n\nRun /agents list to see all available agents.';
return {
type: 'message',
messageType: 'info',
- content: 'Agents reloaded successfully',
+ content,
};
},
};
diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts
index f767805b01..a51c7af12c 100644
--- a/packages/cli/src/ui/commands/bugCommand.test.ts
+++ b/packages/cli/src/ui/commands/bugCommand.test.ts
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { getVersion, type Config } from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
+import { MessageType } from '../types.js';
+import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
+
+const { memoryUsageMock } = vi.hoisted(() => ({
+ memoryUsageMock: vi.fn(() => ({
+ rss: 0,
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ })),
+}));
// Mock dependencies
vi.mock('open');
vi.mock('../utils/formatters.js');
+vi.mock('../utils/memorySnapshot.js', () => ({
+ captureHeapSnapshot: vi.fn(),
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
+}));
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ stat: vi.fn().mockResolvedValue({ size: 4096 }),
+ };
+});
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal();
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
- memoryUsage: () => ({ rss: 0 }),
+ memoryUsage: memoryUsageMock,
},
}));
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(formatBytes).mockReturnValue('100 MB');
+ memoryUsageMock.mockReturnValue({
+ rss: 0,
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
vi.stubEnv('SANDBOX', 'gemini-test');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
expect(open).toHaveBeenCalledWith(expectedUrl);
});
+
+ const buildHighMemoryContext = (tempDir: string | undefined) =>
+ createMockCommandContext({
+ services: {
+ agentContext: {
+ config: {
+ getModel: () => 'gemini-pro',
+ getBugCommand: () => undefined,
+ getIdeMode: () => false,
+ getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
+ storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ } as unknown as Config,
+ geminiClient: { getChat: () => ({ getHistory: () => [] }) },
+ },
+ },
+ });
+
+ it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
+ memoryUsageMock.mockReturnValue({
+ rss: 3 * 1024 * 1024 * 1024,
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
+ vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
+
+ const tempDir = path.join('/tmp', 'gemini-test');
+ const context = buildHighMemoryContext(tempDir);
+
+ if (!bugCommand.action) throw new Error('Action is not defined');
+ await bugCommand.action(context, 'A memory bug');
+
+ const now = new Date('2024-01-01T00:00:00Z').getTime();
+ const expectedSnapshotPath = path.join(
+ tempDir,
+ `bug-memory-${now}.heapsnapshot`,
+ );
+ expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
+
+ const addItem = vi.mocked(context.ui.addItem);
+ const callOrder = addItem.mock.invocationCallOrder;
+ const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
+ // The URL message must precede the "capturing" message so the user sees
+ // the URL before the 20+ second snapshot starts.
+ expect(callOrder[0]).toBeLessThan(openOrder);
+ expect(callOrder[1]).toBeGreaterThan(openOrder);
+ expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
+ expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
+ expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
+ expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
+ });
+
+ it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
+ memoryUsageMock.mockReturnValue({
+ rss: 1 * 1024 * 1024 * 1024,
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
+ const context = buildHighMemoryContext('/tmp/gemini-test');
+
+ if (!bugCommand.action) throw new Error('Action is not defined');
+ await bugCommand.action(context, 'A light bug');
+
+ expect(captureHeapSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('reports an error if the auto-capture fails but does not throw', async () => {
+ memoryUsageMock.mockReturnValue({
+ rss: 3 * 1024 * 1024 * 1024,
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
+ vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
+ new Error('inspector failure'),
+ );
+ const context = buildHighMemoryContext('/tmp/gemini-test');
+
+ if (!bugCommand.action) throw new Error('Action is not defined');
+ await expect(
+ bugCommand.action(context, 'A memory bug'),
+ ).resolves.toBeUndefined();
+
+ const addItem = vi.mocked(context.ui.addItem).mock.calls;
+ const lastCall = addItem[addItem.length - 1][0];
+ expect(lastCall.type).toBe(MessageType.ERROR);
+ expect(lastCall.text).toContain('inspector failure');
+ });
});
diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts
index e146491dec..19bc7183d0 100644
--- a/packages/cli/src/ui/commands/bugCommand.ts
+++ b/packages/cli/src/ui/commands/bugCommand.ts
@@ -22,6 +22,11 @@ import {
} from '@google/gemini-cli-core';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
+import {
+ captureHeapSnapshot,
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
+} from '../utils/memorySnapshot.js';
+import { stat } from 'node:fs/promises';
import path from 'node:path';
export const bugCommand: SlashCommand = {
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
Date.now(),
);
}
+
+ const rss = process.memoryUsage().rss;
+ const tempDir = config?.storage?.getProjectTempDir();
+ if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
+ const snapshotPath = path.join(
+ tempDir,
+ `bug-memory-${Date.now()}.heapsnapshot`,
+ );
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
+ },
+ Date.now(),
+ );
+ try {
+ const startedAt = Date.now();
+ await captureHeapSnapshot(snapshotPath);
+ const durationMs = Date.now() - startedAt;
+ let sizeText = '';
+ try {
+ const { size } = await stat(snapshotPath);
+ sizeText = ` (${formatBytes(size)})`;
+ } catch {
+ // Size reporting is best-effort; the snapshot itself was captured successfully.
+ }
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
+ },
+ Date.now(),
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ debugLogger.error(
+ `Failed to capture heap snapshot for bug report: ${errorMessage}`,
+ );
+ context.ui.addItem(
+ {
+ type: MessageType.ERROR,
+ text: `Failed to capture heap snapshot: ${errorMessage}`,
+ },
+ Date.now(),
+ );
+ }
+ }
},
};
diff --git a/packages/cli/src/ui/commands/bugMemoryCommand.test.ts b/packages/cli/src/ui/commands/bugMemoryCommand.test.ts
new file mode 100644
index 0000000000..8a93db9527
--- /dev/null
+++ b/packages/cli/src/ui/commands/bugMemoryCommand.test.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import path from 'node:path';
+import { bugMemoryCommand } from './bugMemoryCommand.js';
+import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { MessageType } from '../types.js';
+import type { Config } from '@google/gemini-cli-core';
+
+vi.mock('../utils/memorySnapshot.js', () => ({
+ captureHeapSnapshot: vi.fn(),
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
+}));
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ stat: vi.fn().mockResolvedValue({ size: 1234 }),
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ debugLogger: {
+ error: vi.fn(),
+ log: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ },
+ };
+});
+
+function makeContextWithTempDir(tempDir: string | undefined) {
+ return createMockCommandContext({
+ services: {
+ agentContext: {
+ config: {
+ storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
+ } as unknown as Config,
+ },
+ },
+ });
+}
+
+describe('bugMemoryCommand', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('declares itself as a non-auto-executing built-in command', () => {
+ expect(bugMemoryCommand.name).toBe('bug-memory');
+ expect(bugMemoryCommand.autoExecute).toBe(false);
+ expect(bugMemoryCommand.description).toBeTruthy();
+ });
+
+ it('captures a heap snapshot and reports the file path', async () => {
+ const tempDir = path.join('/tmp', 'gemini-test');
+ const context = makeContextWithTempDir(tempDir);
+ vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
+
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
+ await bugMemoryCommand.action(context, '');
+
+ const expectedPath = path.join(
+ tempDir,
+ `bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
+ );
+ expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
+
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
+ expect(addItemCalls).toHaveLength(2);
+ expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
+ expect(addItemCalls[0][0].text).toContain(expectedPath);
+ expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
+ expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
+ expect(addItemCalls[1][0].text).toContain(expectedPath);
+ });
+
+ it('surfaces an error if capture fails', async () => {
+ const context = makeContextWithTempDir('/tmp/gemini-test');
+ vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
+ new Error('inspector disconnected'),
+ );
+
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
+ await bugMemoryCommand.action(context, '');
+
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
+ const lastCall = addItemCalls[addItemCalls.length - 1][0];
+ expect(lastCall.type).toBe(MessageType.ERROR);
+ expect(lastCall.text).toContain('inspector disconnected');
+ });
+
+ it('emits an error when no project temp directory is available', async () => {
+ const context = makeContextWithTempDir(undefined);
+
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
+ await bugMemoryCommand.action(context, '');
+
+ expect(captureHeapSnapshot).not.toHaveBeenCalled();
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
+ expect(addItemCalls).toHaveLength(1);
+ expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
+ expect(addItemCalls[0][0].text).toContain('temp directory');
+ });
+});
diff --git a/packages/cli/src/ui/commands/bugMemoryCommand.ts b/packages/cli/src/ui/commands/bugMemoryCommand.ts
new file mode 100644
index 0000000000..cd43ce8902
--- /dev/null
+++ b/packages/cli/src/ui/commands/bugMemoryCommand.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { stat } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+import { debugLogger } from '@google/gemini-cli-core';
+import {
+ type CommandContext,
+ type SlashCommand,
+ CommandKind,
+} from './types.js';
+import { MessageType } from '../types.js';
+import { formatBytes } from '../utils/formatters.js';
+import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
+
+export const bugMemoryCommand: SlashCommand = {
+ name: 'bug-memory',
+ description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: false,
+ action: async (context: CommandContext): Promise => {
+ const tempDir =
+ context.services.agentContext?.config?.storage?.getProjectTempDir();
+ if (!tempDir) {
+ context.ui.addItem(
+ {
+ type: MessageType.ERROR,
+ text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
+ },
+ Date.now(),
+ );
+ return;
+ }
+
+ const filePath = path.join(
+ tempDir,
+ `bug-memory-${Date.now()}.heapsnapshot`,
+ );
+ const rss = process.memoryUsage().rss;
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive ā please do not exit.\nDestination: ${filePath}`,
+ },
+ Date.now(),
+ );
+
+ const startedAt = Date.now();
+ try {
+ await captureHeapSnapshot(filePath);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ debugLogger.error(`Failed to capture heap snapshot: ${message}`);
+ context.ui.addItem(
+ {
+ type: MessageType.ERROR,
+ text: `Failed to capture heap snapshot: ${message}`,
+ },
+ Date.now(),
+ );
+ return;
+ }
+
+ const durationMs = Date.now() - startedAt;
+ let sizeText = '';
+ try {
+ const { size } = await stat(filePath);
+ sizeText = ` (${formatBytes(size)})`;
+ } catch {
+ // Size reporting is best-effort; the snapshot itself was captured successfully.
+ }
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools ā Memory ā "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
+ },
+ Date.now(),
+ );
+ },
+};
diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts
index 9d7a19990e..5f7144adb8 100644
--- a/packages/cli/src/ui/commands/memoryCommand.ts
+++ b/packages/cli/src/ui/commands/memoryCommand.ts
@@ -18,7 +18,7 @@ import {
type SlashCommand,
type SlashCommandActionReturn,
} from './types.js';
-import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
+import { InboxDialog } from '../components/InboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
return {
type: 'custom_dialog',
- component: React.createElement(SkillInboxDialog, {
+ component: React.createElement(InboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
+ onReloadMemory: async () => {
+ await refreshMemory(config);
+ },
}),
};
},
diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts
index 438f09b182..7cc0629f2e 100644
--- a/packages/cli/src/ui/commands/skillsCommand.test.ts
+++ b/packages/cli/src/ui/commands/skillsCommand.test.ts
@@ -37,6 +37,7 @@ vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
});
import { linkSkill } from '../../utils/skillUtils.js';
+import { requestConsentInteractive } from '../../config/extensions/consent.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
@@ -253,6 +254,36 @@ describe('skillsCommand', () => {
);
});
+ it('should pass a cleanup callback for interactive workspace consent', async () => {
+ const linkCmd = skillsCommand.subCommands!.find(
+ (s) => s.name === 'link',
+ )!;
+ context.ui.setConfirmationRequest = vi.fn();
+ vi.mocked(linkSkill).mockImplementation(
+ async (_sourcePath, _scope, _addItem, requestConsent) => {
+ expect(requestConsent).toBeDefined();
+ await requestConsent!(
+ [{ name: 'test-skill', location: '/path' } as SkillDefinition],
+ '/workspace/.gemini/skills',
+ );
+ return [{ name: 'test-skill', location: '/path' }];
+ },
+ );
+
+ await linkCmd.action!(context, '/some/path --scope workspace');
+
+ const requestConsentCall = vi
+ .mocked(requestConsentInteractive)
+ .mock.calls.at(-1);
+ expect(requestConsentCall?.[1]).toEqual(expect.any(Function));
+
+ const clearConfirmationRequest = requestConsentCall?.[2];
+ expect(clearConfirmationRequest).toBeTypeOf('function');
+
+ clearConfirmationRequest?.();
+ expect(context.ui.setConfirmationRequest).toHaveBeenCalledWith(null);
+ });
+
it('should show error if link fails', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts
index ea1888db40..291186e628 100644
--- a/packages/cli/src/ui/commands/skillsCommand.ts
+++ b/packages/cli/src/ui/commands/skillsCommand.ts
@@ -118,6 +118,7 @@ async function linkAction(
return requestConsentInteractive(
consentString,
context.ui.setConfirmationRequest.bind(context.ui),
+ () => context.ui.setConfirmationRequest(null),
);
},
);
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index 328e8fc5e4..266a3bcf02 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -89,7 +89,7 @@ export interface CommandContext {
*
* @param value The confirmation request details.
*/
- setConfirmationRequest: (value: ConfirmationRequest) => void;
+ setConfirmationRequest: (value: ConfirmationRequest | null) => void;
removeComponent: () => void;
toggleBackgroundTasks: () => void;
toggleShortcutsHelp: () => void;
diff --git a/packages/cli/src/ui/components/SkillInboxDialog.test.tsx b/packages/cli/src/ui/components/InboxDialog.test.tsx
similarity index 52%
rename from packages/cli/src/ui/components/SkillInboxDialog.test.tsx
rename to packages/cli/src/ui/components/InboxDialog.test.tsx
index 7121960021..969b7e9ff4 100644
--- a/packages/cli/src/ui/components/SkillInboxDialog.test.tsx
+++ b/packages/cli/src/ui/components/InboxDialog.test.tsx
@@ -6,19 +6,32 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
+import type {
+ Config,
+ InboxSkill,
+ InboxPatch,
+ InboxMemoryPatch,
+} from '@google/gemini-cli-core';
import {
dismissInboxSkill,
+ dismissInboxMemoryPatch,
listInboxSkills,
listInboxPatches,
+ listInboxMemoryPatches,
moveInboxSkill,
applyInboxPatch,
dismissInboxPatch,
+ applyInboxMemoryPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
-import { SkillInboxDialog } from './SkillInboxDialog.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { InboxDialog } from './InboxDialog.js';
+
+const altBufferSettings = createMockSettings({
+ ui: { useAlternateBuffer: true },
+});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
@@ -27,11 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
dismissInboxSkill: vi.fn(),
+ dismissInboxMemoryPatch: vi.fn(),
listInboxSkills: vi.fn(),
listInboxPatches: vi.fn(),
+ listInboxMemoryPatches: vi.fn(),
moveInboxSkill: vi.fn(),
applyInboxPatch: vi.fn(),
dismissInboxPatch: vi.fn(),
+ applyInboxMemoryPatch: vi.fn(),
isProjectSkillPatchTarget: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
@@ -41,10 +57,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockListInboxPatches = vi.mocked(listInboxPatches);
+const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
+const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
+const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
const inboxSkill: InboxSkill = {
@@ -76,6 +95,27 @@ const inboxPatch: InboxPatch = {
extractedAt: '2025-01-20T14:00:00Z',
};
+const inboxMemoryPatch: InboxMemoryPatch = {
+ kind: 'private',
+ relativePath: 'private',
+ name: 'Private memory',
+ sourceFiles: ['update-memory.patch'],
+ entries: [
+ {
+ targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
+ isNewFile: false,
+ diffContent: [
+ '--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
+ '+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
+ '@@ -1,1 +1,1 @@',
+ '-old',
+ '+use focused tests',
+ ].join('\n'),
+ },
+ ],
+ extractedAt: '2025-01-21T10:00:00Z',
+};
+
const workspacePatch: InboxPatch = {
fileName: 'workspace-update.patch',
name: 'workspace-update',
@@ -137,11 +177,12 @@ const windowsGlobalPatch: InboxPatch = {
],
};
-describe('SkillInboxDialog', () => {
+describe('InboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
@@ -158,6 +199,14 @@ describe('SkillInboxDialog', () => {
success: true,
message: 'Dismissed "update-docs.patch" from inbox.',
});
+ mockApplyInboxMemoryPatch.mockResolvedValue({
+ success: true,
+ message: 'Applied memory patch to 1 file.',
+ });
+ mockDismissInboxMemoryPatch.mockResolvedValue({
+ success: true,
+ message: 'Dismissed 1 private memory patch from inbox.',
+ });
mockIsProjectSkillPatchTarget.mockImplementation(
async (targetPath: string, config: Config) => {
const projectSkillsDir = config.storage
@@ -176,6 +225,64 @@ describe('SkillInboxDialog', () => {
vi.unstubAllEnvs();
});
+ it('reviews and applies memory patches', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const onReloadMemory = vi.fn().mockResolvedValue(undefined);
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Private memory');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Review');
+ expect(frame).toMatch(/source patch/);
+ });
+
+ // Memory patches default to Dismiss as the highlighted action so a stray
+ // Enter cannot apply durable changes. Arrow-down to reach Apply, then
+ // press Enter to confirm.
+ await act(async () => {
+ stdin.write('\u001B[B'); // arrow down ā Apply
+ await waitUntilReady();
+ });
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ // Aggregate apply: relativePath equals the kind name.
+ expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
+ config,
+ 'private',
+ 'private',
+ );
+ expect(onReloadMemory).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
it('disables the project destination when the workspace is untrusted', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
@@ -183,7 +290,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
- {
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
- {
.mockRejectedValue(new Error('reload hook failed'));
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
- {
unmount();
});
+ it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
+ // Reproduces the bug where pressing Esc from the apply dialog re-rendered
+ // the list with focus jumped back to row 0 instead of staying on the row
+ // the user was on.
+ const secondSkill: InboxSkill = {
+ ...inboxSkill,
+ dirName: 'second-skill',
+ name: 'Second Skill',
+ };
+ mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Inbox Skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Arrow down to the second row.
+ await act(async () => {
+ stdin.write('\x1b[B');
+ await waitUntilReady();
+ });
+
+ // Enter the second row's preview.
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Review new skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Esc back to list.
+ await act(async () => {
+ stdin.write('\x1b');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Inbox Skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Re-enter (no arrow keys this time). The active row must still be the
+ // SECOND skill, not the first ā which is what the bug reproduced before.
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Review new skill');
+ // The preview header echoes the highlighted skill's name.
+ expect(frame).toContain('Second Skill');
+ });
+
+ unmount();
+ });
+
describe('patch support', () => {
it('shows patches alongside skills with section headers', async () => {
mockListInboxPatches.mockResolvedValue([inboxPatch]);
@@ -328,7 +512,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
- {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
- {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
- {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
- {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
- {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
- {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
- {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
- {
consoleErrorSpy.mockRestore();
unmount();
});
+
+ const tallPatch: InboxPatch = {
+ fileName: 'tall.patch',
+ name: 'tall-patch',
+ entries: [
+ {
+ targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /repo/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /repo/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -1,4 +1,8 @@',
+ ' line1',
+ ' line2',
+ '+added-1',
+ '+added-2',
+ '+added-3',
+ '+added-4',
+ ' line3',
+ ' line4',
+ ].join('\n'),
+ },
+ ],
+ };
+
+ it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
+ // Alt-buffer mode has no terminal scrollback, so the dialog must
+ // scroll inside itself. ScrollableList renders a `ā` thumb when
+ // content exceeds viewport height ā the regression signal that the
+ // diff is bounded and off-screen content is reachable via PgUp/PgDn.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ {
+ settings: altBufferSettings,
+ uiState: { terminalHeight: 18 },
+ },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Apply');
+ expect(frame).toContain('Dismiss');
+ expect(frame).toContain('ā');
+ });
+
+ unmount();
+ });
+
+ it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { settings: altBufferSettings },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('update-docs');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('PgUp/PgDn to scroll');
+ });
+
+ unmount();
+ });
+
+ it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
+ // Non-alt-buffer mode uses the codebase's standard bounded
+ // DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
+ // FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
+ // "... first/last N line(s) hidden ..." hint when it clips, which
+ // is the regression signal that the diff is bounded.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18, constrainHeight: true } },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame() ?? '').toMatch(/lines? hidden/);
+ });
+
+ unmount();
+ });
+
+ it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
+ // In non-alt-buffer mode the Ctrl+O affordance is rendered inline
+ // by ShowMoreLines above the footer when the diff is clipped. The
+ // footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
+ // duplicating the hint there would be noisy.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18, constrainHeight: true } },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Ctrl+O');
+ expect(frame).not.toContain('PgUp/PgDn to scroll');
+ });
+
+ unmount();
+ });
+ });
+
+ it('renders each list row as exactly two lines even with long descriptions', async () => {
+ // Reproduces the production bug: with the previous renderer, long
+ // descriptions wrapped onto multiple lines (and the date sibling was
+ // interleaved into the wrap), making each item 3-5 rows tall and
+ // breaking the listMaxItemsToShow budget. The fix uses height={2}
+ // and wrap="truncate-end" on every list row.
+ const longDescription =
+ 'This is an extremely long description that would absolutely wrap to ' +
+ 'multiple lines if rendered without truncation, which used to push the ' +
+ 'list-phase footer off the bottom of the alternate buffer in production.';
+ mockListInboxSkills.mockResolvedValue([
+ {
+ dirName: 'long-skill',
+ name: 'long-skill',
+ description: longDescription,
+ content: '---\nname: x\ndescription: y\n---\n',
+ },
+ ]);
+ mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('long-skill');
+ });
+
+ const frame = lastFrame() ?? '';
+ expect(frame).not.toContain('production');
+ expect(frame).toContain('extremely long description');
+
+ unmount();
+ });
+
+ it('keeps the list-phase footer on screen with many long-description skills', async () => {
+ const longDesc =
+ 'A very long description that would wrap across multiple lines if not ' +
+ 'truncated, which was causing the dialog body to overflow the bottom ' +
+ 'of the alternate buffer';
+ const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
+ dirName: `skill-${i}`,
+ name: `skill-${i}`,
+ description: `${longDesc} (#${i})`,
+ content: '---\nname: x\ndescription: y\n---\n',
+ }));
+ mockListInboxSkills.mockResolvedValue(manySkills);
+ mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 28 } },
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Memory Inbox');
+ expect(frame).toContain('Esc to close');
+ });
+
+ unmount();
+ });
+
+ it('keeps the list-phase footer on screen on short terminals', async () => {
+ const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
+ dirName: `skill-${i}`,
+ name: `Skill ${i}`,
+ description: `Description ${i}`,
+ content: '---\nname: Skill\ndescription: Skill\n---\n',
+ }));
+ mockListInboxSkills.mockResolvedValue(manySkills);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18 } },
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Memory Inbox');
+ expect(frame).toContain('Esc to close');
+ });
+
+ unmount();
});
});
diff --git a/packages/cli/src/ui/components/InboxDialog.tsx b/packages/cli/src/ui/components/InboxDialog.tsx
new file mode 100644
index 0000000000..3da004266c
--- /dev/null
+++ b/packages/cli/src/ui/components/InboxDialog.tsx
@@ -0,0 +1,1446 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import type React from 'react';
+import { Fragment, useState, useMemo, useCallback, useEffect } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+import { BaseSelectionList } from './shared/BaseSelectionList.js';
+import type { SelectionListItem } from '../hooks/useSelectionList.js';
+import { DialogFooter } from './shared/DialogFooter.js';
+import {
+ DiffRenderer,
+ parseDiffWithLineNumbers,
+ renderDiffLines,
+ type DiffLine,
+} from './messages/DiffRenderer.js';
+import { ScrollableList } from './shared/ScrollableList.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import {
+ type Config,
+ type InboxSkill,
+ type InboxPatch,
+ type InboxMemoryPatch,
+ type InboxSkillDestination,
+ getErrorMessage,
+ listInboxSkills,
+ listInboxPatches,
+ listInboxMemoryPatches,
+ moveInboxSkill,
+ dismissInboxSkill,
+ applyInboxPatch,
+ dismissInboxPatch,
+ applyInboxMemoryPatch,
+ dismissInboxMemoryPatch,
+ isProjectSkillPatchTarget,
+} from '@google/gemini-cli-core';
+
+type Phase =
+ | 'list'
+ | 'skill-preview'
+ | 'skill-action'
+ | 'patch-preview'
+ | 'memory-preview';
+
+type InboxItem =
+ | { type: 'skill'; skill: InboxSkill }
+ | { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
+ | { type: 'memory-patch'; memoryPatch: InboxMemoryPatch }
+ | { type: 'header'; label: string };
+
+interface DestinationChoice {
+ destination: InboxSkillDestination;
+ label: string;
+ description: string;
+}
+
+interface PatchAction {
+ action: 'apply' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+interface MemoryPatchAction {
+ action: 'apply' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
+ {
+ destination: 'global',
+ label: 'Global',
+ description: '~/.gemini/skills ā available in all projects',
+ },
+ {
+ destination: 'project',
+ label: 'Project',
+ description: '.gemini/skills ā available in this workspace',
+ },
+];
+
+interface SkillPreviewAction {
+ action: 'move' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
+ {
+ action: 'move',
+ label: 'Move',
+ description: 'Choose where to install this skill',
+ },
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox',
+ },
+];
+
+const PATCH_ACTION_CHOICES: PatchAction[] = [
+ {
+ action: 'apply',
+ label: 'Apply',
+ description: 'Apply patch and delete from inbox',
+ },
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox without applying',
+ },
+];
+
+// Dismiss-first: memory patches modify durable on-disk state outside the
+// project (private MEMORY.md and sibling files, plus ~/.gemini/GEMINI.md),
+// so a stray Enter on a freshly-opened memory-patch preview must NOT apply.
+// The lower-stakes skill-patch list (PATCH_ACTION_CHOICES) keeps Apply as
+// the default.
+const MEMORY_PATCH_ACTION_CHOICES: MemoryPatchAction[] = [
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox without applying',
+ },
+ {
+ action: 'apply',
+ label: 'Apply',
+ description: 'Apply patch and delete from inbox',
+ },
+];
+
+function normalizePathForUi(filePath: string): string {
+ return path.posix.normalize(filePath.replaceAll('\\', '/'));
+}
+
+function getPathBasename(filePath: string): string {
+ const normalizedPath = normalizePathForUi(filePath);
+ const basename = path.posix.basename(normalizedPath);
+ return basename === '.' ? filePath : basename;
+}
+
+function formatMemoryPatchSummary(patch: InboxMemoryPatch): string {
+ const hunkCount = patch.entries.length;
+ const sourceCount = patch.sourceFiles.length;
+ const hunkLabel = hunkCount === 1 ? 'hunk' : 'hunks';
+ const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
+ return `${hunkCount} ${hunkLabel} from ${sourceCount} source ${sourceLabel}`;
+}
+
+async function patchTargetsProjectSkills(
+ patch: InboxPatch,
+ config: Config,
+): Promise {
+ const entryTargetsProjectSkills = await Promise.all(
+ patch.entries.map((entry) =>
+ isProjectSkillPatchTarget(entry.targetPath, config),
+ ),
+ );
+ return entryTargetsProjectSkills.some(Boolean);
+}
+
+/**
+ * Derives a bracketed origin tag from a skill file path,
+ * matching the existing [Built-in] convention in SkillsList.
+ */
+function getSkillOriginTag(filePath: string): string {
+ const normalizedPath = normalizePathForUi(filePath);
+
+ if (normalizedPath.includes('/bundle/')) {
+ return 'Built-in';
+ }
+ if (normalizedPath.includes('/extensions/')) {
+ return 'Extension';
+ }
+ if (normalizedPath.includes('/.gemini/skills/')) {
+ const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
+ .filter((homeDir): homeDir is string => Boolean(homeDir))
+ .map(normalizePathForUi);
+ if (
+ homeDirs.some((homeDir) =>
+ normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
+ )
+ ) {
+ return 'Global';
+ }
+ return 'Workspace';
+ }
+ return '';
+}
+
+/**
+ * Creates a unified diff string representing a new file.
+ */
+function newFileDiff(filename: string, content: string): string {
+ const lines = content.split('\n');
+ const hunkLines = lines.map((l) => `+${l}`).join('\n');
+ return [
+ `--- /dev/null`,
+ `+++ ${filename}`,
+ `@@ -0,0 +1,${lines.length} @@`,
+ hunkLines,
+ ].join('\n');
+}
+
+function formatDate(isoString: string): string {
+ try {
+ const date = new Date(isoString);
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ } catch {
+ return isoString;
+ }
+}
+
+interface DiffSection {
+ /** Stable identifier for the section (e.g. patch entry path + index). */
+ key: string;
+ /** Header rendered above the diff body, e.g. file path or "SKILL.md". */
+ header: string;
+ /** Raw unified-diff string. Parsed via parseDiffWithLineNumbers. */
+ diffContent: string;
+}
+
+interface DiffViewportItem {
+ key: string;
+ /** Pre-rendered React node for this row. */
+ element: React.ReactElement;
+}
+
+/**
+ * A fixed-height, scrollable diff viewer used by the skill, patch, and
+ * memory-patch preview phases. It flattens one or more DiffSections into
+ * individual line items so ScrollableList can virtualize and so
+ * PgUp/PgDn/Shift+arrows move the viewport over arbitrarily long diffs
+ * without overflowing the alternate buffer.
+ *
+ * The visual styling matches DiffRenderer's renderDiffLines path; we share
+ * that helper instead of nesting DiffRenderer (whose own MaxSizedBox
+ * wrapping would interfere with virtualization).
+ */
+const ScrollableDiffViewport: React.FC<{
+ sections: DiffSection[];
+ width: number;
+ height: number;
+ hasFocus: boolean;
+}> = ({ sections, width, height, hasFocus }) => {
+ const items = useMemo(() => {
+ const result: DiffViewportItem[] = [];
+ sections.forEach((section, sectionIndex) => {
+ // Header (with a blank spacer row above for separation between
+ // sections ā skipped above the first section).
+ if (sectionIndex > 0) {
+ result.push({
+ key: `${section.key}:spacer`,
+ element: ,
+ });
+ }
+ result.push({
+ key: `${section.key}:header`,
+ element: (
+
+ {section.header}
+
+ ),
+ });
+
+ const parsed: DiffLine[] = parseDiffWithLineNumbers(section.diffContent);
+ const rendered = renderDiffLines({
+ parsedLines: parsed,
+ filename: section.header,
+ terminalWidth: width,
+ });
+ rendered.forEach((node, index) => {
+ result.push({
+ key: `${section.key}:line:${index}`,
+ // renderDiffLines emits ReactNodes with their own keys; wrap each
+ // in a Fragment so ScrollableList sees a single ReactElement per
+ // row regardless of node shape.
+ element: {node},
+ });
+ });
+ });
+ return result;
+ }, [sections, width]);
+
+ const renderItem = useCallback(
+ ({ item }: { item: DiffViewportItem }) => item.element,
+ [],
+ );
+ const keyExtractor = useCallback((item: DiffViewportItem) => item.key, []);
+ // Most diff rows are exactly one line tall; long lines wrap so this is a
+ // lower bound. ScrollableList re-measures via ResizeObserver, so the
+ // estimate only matters for initial sizing.
+ const estimatedItemHeight = useCallback(() => 1, []);
+
+ return (
+
+
+ data={items}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
+ estimatedItemHeight={estimatedItemHeight}
+ hasFocus={hasFocus}
+ initialScrollIndex={0}
+ scrollbar={true}
+ />
+
+ );
+};
+
+interface InboxDialogProps {
+ config: Config;
+ onClose: () => void;
+ onReloadSkills: () => Promise;
+ onReloadMemory?: () => Promise;
+}
+
+export const InboxDialog: React.FC = ({
+ config,
+ onClose,
+ onReloadSkills,
+ onReloadMemory,
+}) => {
+ const keyMatchers = useKeyMatchers();
+ const { terminalWidth, terminalHeight, constrainHeight } = useUIState();
+ const isAlternateBuffer = useAlternateBuffer();
+ const isTrustedFolder = config.isTrustedFolder();
+ const [phase, setPhase] = useState('list');
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [feedback, setFeedback] = useState<{
+ text: string;
+ isError: boolean;
+ } | null>(null);
+ // Tracks the most recent highlighted/selected position in the list so we
+ // can restore focus when the user backs out of a sub-phase (e.g. ESC from
+ // the apply dialog) instead of jumping back to the top of the list.
+ const [lastListIndex, setLastListIndex] = useState(0);
+
+ // Load inbox skills and patches on mount
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const [skills, patches, memoryPatches] = await Promise.all([
+ listInboxSkills(config),
+ listInboxPatches(config),
+ listInboxMemoryPatches(config),
+ ]);
+ const patchItems = await Promise.all(
+ patches.map(async (patch): Promise => {
+ let targetsProjectSkills = false;
+ try {
+ targetsProjectSkills = await patchTargetsProjectSkills(
+ patch,
+ config,
+ );
+ } catch {
+ targetsProjectSkills = false;
+ }
+
+ return {
+ type: 'patch',
+ patch,
+ targetsProjectSkills,
+ };
+ }),
+ );
+ if (!cancelled) {
+ const combined: InboxItem[] = [
+ ...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
+ ...patchItems,
+ ...memoryPatches.map(
+ (memoryPatch): InboxItem => ({
+ type: 'memory-patch',
+ memoryPatch,
+ }),
+ ),
+ ];
+ setItems(combined);
+ setLoading(false);
+ }
+ } catch {
+ if (!cancelled) {
+ setItems([]);
+ setLoading(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [config]);
+
+ const getItemKey = useCallback(
+ (item: InboxItem): string =>
+ item.type === 'skill'
+ ? `skill:${item.skill.dirName}`
+ : item.type === 'patch'
+ ? `patch:${item.patch.fileName}`
+ : item.type === 'memory-patch'
+ ? `memory:${item.memoryPatch.kind}:${item.memoryPatch.relativePath}`
+ : `header:${item.label}`,
+ [],
+ );
+
+ const listItems: Array> = useMemo(() => {
+ const skills = items.filter((i) => i.type === 'skill');
+ const patches = items.filter((i) => i.type === 'patch');
+ const memoryPatches = items.filter((i) => i.type === 'memory-patch');
+ const result: Array> = [];
+
+ const groups: Array<{ label: string; items: InboxItem[] }> = [
+ { label: 'New Skills', items: skills },
+ { label: 'Skill Updates', items: patches },
+ { label: 'Memory Updates', items: memoryPatches },
+ ].filter((group) => group.items.length > 0);
+ const showHeaders = groups.length > 1;
+
+ for (const group of groups) {
+ if (showHeaders) {
+ const header: InboxItem = { type: 'header', label: group.label };
+ result.push({
+ key: `header:${group.label}`,
+ value: header,
+ disabled: true,
+ hideNumber: true,
+ });
+ }
+ for (const item of group.items) {
+ result.push({ key: getItemKey(item), value: item });
+ }
+ }
+
+ return result;
+ }, [items, getItemKey]);
+
+ const destinationItems: Array> = useMemo(
+ () =>
+ SKILL_DESTINATION_CHOICES.map((choice) => {
+ if (choice.destination === 'project' && !isTrustedFolder) {
+ return {
+ key: choice.destination,
+ value: {
+ ...choice,
+ description:
+ '.gemini/skills ā unavailable until this workspace is trusted',
+ },
+ disabled: true,
+ };
+ }
+
+ return {
+ key: choice.destination,
+ value: choice,
+ };
+ }),
+ [isTrustedFolder],
+ );
+
+ const selectedPatchTargetsProjectSkills = useMemo(() => {
+ if (!selectedItem || selectedItem.type !== 'patch') {
+ return false;
+ }
+
+ return selectedItem.targetsProjectSkills;
+ }, [selectedItem]);
+
+ const patchActionItems: Array> = useMemo(
+ () =>
+ PATCH_ACTION_CHOICES.map((choice) => {
+ if (
+ choice.action === 'apply' &&
+ selectedPatchTargetsProjectSkills &&
+ !isTrustedFolder
+ ) {
+ return {
+ key: choice.action,
+ value: {
+ ...choice,
+ description:
+ '.gemini/skills ā unavailable until this workspace is trusted',
+ },
+ disabled: true,
+ };
+ }
+
+ return {
+ key: choice.action,
+ value: choice,
+ };
+ }),
+ [isTrustedFolder, selectedPatchTargetsProjectSkills],
+ );
+
+ const skillPreviewItems: Array> =
+ useMemo(
+ () =>
+ SKILL_PREVIEW_CHOICES.map((choice) => ({
+ key: choice.action,
+ value: choice,
+ })),
+ [],
+ );
+
+ const memoryPatchActionItems: Array> =
+ useMemo(
+ () =>
+ MEMORY_PATCH_ACTION_CHOICES.map((choice) => ({
+ key: choice.action,
+ value: choice,
+ })),
+ [],
+ );
+
+ const handleSelectItem = useCallback(
+ (item: InboxItem) => {
+ setSelectedItem(item);
+ setFeedback(null);
+ // Remember which list row we navigated away from so ESC restores focus
+ // instead of jumping the cursor back to the top of the list.
+ const idx = listItems.findIndex((i) => i.value === item);
+ if (idx >= 0) {
+ setLastListIndex(idx);
+ }
+ setPhase(
+ item.type === 'skill'
+ ? 'skill-preview'
+ : item.type === 'patch'
+ ? 'patch-preview'
+ : 'memory-preview',
+ );
+ },
+ [listItems],
+ );
+
+ const removeItem = useCallback(
+ (item: InboxItem) => {
+ setItems((prev) =>
+ prev.filter((i) => getItemKey(i) !== getItemKey(item)),
+ );
+ },
+ [getItemKey],
+ );
+
+ const handleSkillPreviewAction = useCallback(
+ (choice: SkillPreviewAction) => {
+ if (!selectedItem || selectedItem.type !== 'skill') return;
+
+ if (choice.action === 'move') {
+ setFeedback(null);
+ setPhase('skill-action');
+ return;
+ }
+
+ // Dismiss
+ setFeedback(null);
+ const skill = selectedItem.skill;
+ void (async () => {
+ try {
+ const result = await dismissInboxSkill(config, skill.dirName);
+ setFeedback({ text: result.message, isError: !result.success });
+ if (result.success) {
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+ }
+ } catch (error) {
+ setFeedback({
+ text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, removeItem],
+ );
+
+ const handleSelectDestination = useCallback(
+ (choice: DestinationChoice) => {
+ if (!selectedItem || selectedItem.type !== 'skill') return;
+ const skill = selectedItem.skill;
+
+ if (choice.destination === 'project' && !config.isTrustedFolder()) {
+ setFeedback({
+ text: 'Project skills are unavailable until this workspace is trusted.',
+ isError: true,
+ });
+ return;
+ }
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ const result = await moveInboxSkill(
+ config,
+ skill.dirName,
+ choice.destination,
+ );
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ try {
+ await onReloadSkills();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ } catch (error) {
+ setFeedback({
+ text: `Failed to install skill: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadSkills, removeItem],
+ );
+
+ const handleSelectPatchAction = useCallback(
+ (choice: PatchAction) => {
+ if (!selectedItem || selectedItem.type !== 'patch') return;
+ const patch = selectedItem.patch;
+
+ if (
+ choice.action === 'apply' &&
+ !config.isTrustedFolder() &&
+ selectedItem.targetsProjectSkills
+ ) {
+ setFeedback({
+ text: 'Project skill patches are unavailable until this workspace is trusted.',
+ isError: true,
+ });
+ return;
+ }
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ let result: { success: boolean; message: string };
+ if (choice.action === 'apply') {
+ result = await applyInboxPatch(config, patch.fileName);
+ } else {
+ result = await dismissInboxPatch(config, patch.fileName);
+ }
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ if (choice.action === 'apply') {
+ try {
+ await onReloadSkills();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ }
+ } catch (error) {
+ const operation =
+ choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
+ setFeedback({
+ text: `Failed to ${operation}: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadSkills, removeItem],
+ );
+
+ const handleSelectMemoryPatchAction = useCallback(
+ (choice: MemoryPatchAction) => {
+ if (!selectedItem || selectedItem.type !== 'memory-patch') return;
+ const memoryPatch = selectedItem.memoryPatch;
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ let result: { success: boolean; message: string };
+ if (choice.action === 'apply') {
+ result = await applyInboxMemoryPatch(
+ config,
+ memoryPatch.kind,
+ memoryPatch.relativePath,
+ );
+ } else {
+ result = await dismissInboxMemoryPatch(
+ config,
+ memoryPatch.kind,
+ memoryPatch.relativePath,
+ );
+ }
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ if (choice.action === 'apply' && onReloadMemory) {
+ try {
+ await onReloadMemory();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload memory: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ }
+ } catch (error) {
+ const operation =
+ choice.action === 'apply'
+ ? 'apply memory patch'
+ : 'dismiss memory patch';
+ setFeedback({
+ text: `Failed to ${operation}: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadMemory, removeItem],
+ );
+
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ if (phase === 'skill-action') {
+ setPhase('skill-preview');
+ setFeedback(null);
+ } else if (phase !== 'list') {
+ setPhase('list');
+ setSelectedItem(null);
+ setFeedback(null);
+ } else {
+ onClose();
+ }
+ return true;
+ }
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ // Hoist the per-phase preview data so the array literals passed to
+ // ScrollableDiffViewport don't change identity on every parent render.
+ // ScrollableDiffViewport memoizes its expensive `parseDiffWithLineNumbers`
+ // + `renderDiffLines` on `sections`, so a new array literal every render
+ // would defeat that and re-colorize the diff each time. Keying on
+ // `selectedItem` captures every input that affects the rendered diffs.
+ // Must live above the early returns below so React sees a consistent
+ // hook order.
+ const previewData = useMemo(() => {
+ if (!selectedItem) {
+ return {
+ skillSections: undefined as DiffSection[] | undefined,
+ patchSections: undefined as DiffSection[] | undefined,
+ memoryGroups: undefined as
+ | Array<[string, { isNewFile: boolean; diffs: string[] }]>
+ | undefined,
+ memorySections: undefined as DiffSection[] | undefined,
+ };
+ }
+
+ if (selectedItem.type === 'skill') {
+ const skill = selectedItem.skill;
+ if (!skill.content) {
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+ return {
+ skillSections: [
+ {
+ key: `skill:${skill.dirName}`,
+ header: 'SKILL.md',
+ diffContent: newFileDiff('SKILL.md', skill.content),
+ },
+ ],
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+
+ if (selectedItem.type === 'patch') {
+ const patch = selectedItem.patch;
+ return {
+ skillSections: undefined,
+ patchSections: patch.entries.map((entry, index) => ({
+ key: `${patch.fileName}:${entry.targetPath}:${index}`,
+ header: entry.targetPath,
+ diffContent: entry.diffContent,
+ })),
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+
+ if (selectedItem.type === 'memory-patch') {
+ // Group hunks by target file. Multiple source patches may touch the
+ // same file (e.g. several patches all updating MEMORY.md); showing
+ // the file path once with all its hunks beneath is less noisy than
+ // repeating the path for every hunk.
+ const groups = new Map();
+ for (const entry of selectedItem.memoryPatch.entries) {
+ const existing = groups.get(entry.targetPath);
+ if (existing) {
+ existing.diffs.push(entry.diffContent);
+ if (entry.isNewFile) existing.isNewFile = true;
+ } else {
+ groups.set(entry.targetPath, {
+ isNewFile: entry.isNewFile,
+ diffs: [entry.diffContent],
+ });
+ }
+ }
+ const memoryGroups = Array.from(groups.entries());
+
+ const memorySections: DiffSection[] = [];
+ memoryGroups.forEach(([targetPath, { isNewFile, diffs }], groupIndex) => {
+ const headerAnnotation = `${isNewFile ? ' (new file)' : ''}${
+ diffs.length > 1
+ ? ` Ā· ${diffs.length} changes from different patches`
+ : ''
+ }`;
+ diffs.forEach((diff, hunkIndex) => {
+ memorySections.push({
+ key: `${targetPath}:${groupIndex}:${hunkIndex}`,
+ header:
+ hunkIndex === 0 ? `${targetPath}${headerAnnotation}` : targetPath,
+ diffContent: diff,
+ });
+ });
+ });
+
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups,
+ memorySections,
+ };
+ }
+
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }, [selectedItem]);
+
+ if (loading) {
+ return (
+
+ Loading inboxā¦
+
+ );
+ }
+
+ if (items.length === 0 && !feedback) {
+ return (
+
+ Memory Inbox
+
+ No items in inbox.
+
+
+
+ );
+ }
+
+ // Border + paddingX account for 6 chars of width
+ const contentWidth = terminalWidth - 6;
+
+ // Diff-rendering budgets. Two strategies, picked by `isAlternateBuffer`:
+ //
+ // - Alt-buffer: a fixed-height ScrollableList viewport. There is no
+ // terminal scrollback, so we must scroll inside the dialog itself
+ // via PgUp/PgDn/Shift+arrows.
+ //
+ // - Non-alt-buffer: the codebase's standard pattern of a bounded
+ // DiffRenderer + ShowMoreLines + Ctrl+O (see FolderTrustDialog,
+ // ThemeDialog). Clipped content lands in terminal scrollback when
+ // the user expands via Ctrl+O.
+ //
+ // Chrome accounts for the dialog's borders, padding, title + subtitle,
+ // action list (two `minHeight={2}` rows), the section's `marginTop`,
+ // the dialog footer, and a couple of safety rows. Bumped when inline
+ // feedback is showing.
+ const DIALOG_CHROME_HEIGHT = 16;
+ const feedbackHeight = feedback ? 2 : 0;
+ const diffViewportHeight = Math.max(
+ 3,
+ terminalHeight - DIALOG_CHROME_HEIGHT - feedbackHeight,
+ );
+
+ // For the non-alt-buffer DiffRenderer path, mirror MainContent /
+ // DialogManager and drop the clamp when the user has pressed Ctrl+O.
+ const availableContentHeight = constrainHeight
+ ? diffViewportHeight
+ : undefined;
+ const PATCH_ENTRY_OVERHEAD = 2; // target-path label + marginBottom
+ const patchEntryCount =
+ selectedItem?.type === 'patch'
+ ? selectedItem.patch.entries.length
+ : selectedItem?.type === 'memory-patch'
+ ? selectedItem.memoryPatch.entries.length
+ : 1;
+ const availablePatchEntryHeight =
+ availableContentHeight === undefined
+ ? undefined
+ : Math.max(
+ 3,
+ Math.floor(
+ (availableContentHeight - patchEntryCount * PATCH_ENTRY_OVERHEAD) /
+ Math.max(1, patchEntryCount),
+ ),
+ );
+
+ const previewNavigationHint = isAlternateBuffer
+ ? 'PgUp/PgDn to scroll'
+ : undefined;
+
+ // Budget the list phase so the dialog footer never clips on shorter
+ // terminals. Every visible row ā skill items, patch items, memory-patch
+ // items, and the section headers ā renders at exactly 2 rows tall
+ // (enforced by `height={2}` on item renders and `marginTop={1}` + 1
+ // text line for headers), so the windowed-slot count maps directly to
+ // terminal rows.
+ //
+ // Chrome rows accounted for:
+ // - round border (2)
+ // - paddingY (2)
+ // - DefaultAppLayout's alt-buffer paddingBottom (1)
+ // - title + subtitle (2)
+ // - marginTop above the list (1)
+ // - dialog footer marginTop + text (2)
+ // - BaseSelectionList ā² + ā¼ scroll arrows (2) ā always shown when
+ // items > maxItemsToShow, which is precisely when this budget
+ // matters
+ const LIST_PHASE_CHROME_HEIGHT = 12;
+ const LIST_ROW_HEIGHT = 2;
+ const listMaxItemsToShow = Math.max(
+ 1,
+ Math.min(
+ 8,
+ Math.floor(
+ (terminalHeight - LIST_PHASE_CHROME_HEIGHT - feedbackHeight) /
+ LIST_ROW_HEIGHT,
+ ),
+ ),
+ );
+
+ return (
+
+
+ {phase === 'list' && (
+ <>
+
+ Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
+
+
+ Extracted from past sessions. Select one to review.
+
+
+
+
+ items={listItems}
+ initialIndex={Math.max(
+ 0,
+ Math.min(lastListIndex, listItems.length - 1),
+ )}
+ onSelect={handleSelectItem}
+ isFocused={true}
+ showNumbers={false}
+ showScrollArrows={true}
+ maxItemsToShow={listMaxItemsToShow}
+ renderItem={(item, { titleColor }) => {
+ if (item.value.type === 'header') {
+ return (
+
+
+ {item.value.label}
+
+
+ );
+ }
+ if (item.value.type === 'skill') {
+ const skill = item.value.skill;
+ const subtitle = skill.extractedAt
+ ? `${skill.description} Ā· ${formatDate(skill.extractedAt)}`
+ : skill.description;
+ return (
+
+
+ {skill.name}
+
+
+ {subtitle}
+
+
+ );
+ }
+ if (item.value.type === 'memory-patch') {
+ const memoryPatch = item.value.memoryPatch;
+ const summary = formatMemoryPatchSummary(memoryPatch);
+ const subtitle = memoryPatch.extractedAt
+ ? `${summary} Ā· ${formatDate(memoryPatch.extractedAt)}`
+ : summary;
+ return (
+
+
+ {memoryPatch.name}
+
+
+ {subtitle}
+
+
+ );
+ }
+ const patch = item.value.patch;
+ const fileNames = patch.entries.map((e) =>
+ getPathBasename(e.targetPath),
+ );
+ const origin = getSkillOriginTag(
+ patch.entries[0]?.targetPath ?? '',
+ );
+ const titleLine = origin
+ ? `${patch.name} [${origin}]`
+ : patch.name;
+ const subtitle = patch.extractedAt
+ ? `${fileNames.join(', ')} Ā· ${formatDate(patch.extractedAt)}`
+ : fileNames.join(', ');
+ return (
+
+
+ {titleLine}
+
+
+ {subtitle}
+
+
+ );
+ }}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? 'ā ' : 'ā '}
+ {feedback.text}
+
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'skill-preview' && selectedItem?.type === 'skill' && (
+ <>
+ {selectedItem.skill.name}
+
+ Review new skill before installing.
+
+
+ {selectedItem.skill.content &&
+ (isAlternateBuffer ? (
+
+
+
+ ) : (
+
+
+ SKILL.md
+
+
+
+ ))}
+
+
+
+ items={skillPreviewItems}
+ onSelect={handleSkillPreviewAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? 'ā ' : 'ā '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'skill-action' && selectedItem?.type === 'skill' && (
+ <>
+ Move "{selectedItem.skill.name}"
+
+ Choose where to install this skill.
+
+
+
+
+ items={destinationItems}
+ onSelect={handleSelectDestination}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? 'ā ' : 'ā '}
+ {feedback.text}
+
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'patch-preview' && selectedItem?.type === 'patch' && (
+ <>
+ {selectedItem.patch.name}
+
+
+ Review changes before applying.
+
+ {(() => {
+ const origin = getSkillOriginTag(
+ selectedItem.patch.entries[0]?.targetPath ?? '',
+ );
+ return origin ? (
+ {` [${origin}]`}
+ ) : null;
+ })()}
+
+
+
+ {isAlternateBuffer ? (
+
+ ) : (
+ selectedItem.patch.entries.map((entry, index) => (
+
+
+ {entry.targetPath}
+
+
+
+ ))
+ )}
+
+
+
+
+ items={patchActionItems}
+ onSelect={handleSelectPatchAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? 'ā ' : 'ā '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'memory-preview' &&
+ selectedItem?.type === 'memory-patch' && (
+ <>
+ {selectedItem.memoryPatch.name}
+
+ Review {formatMemoryPatchSummary(selectedItem.memoryPatch)}{' '}
+ before applying. Apply runs each source patch atomically;
+ Dismiss removes them all.
+
+
+ {(() => {
+ // Grouping + section flattening were hoisted into the
+ // `previewData` useMemo so the array identities passed into
+ // ScrollableDiffViewport stay stable across re-renders.
+ const groupEntries = previewData.memoryGroups ?? [];
+
+ if (isAlternateBuffer) {
+ return (
+
+
+
+ );
+ }
+
+ return groupEntries.map(
+ ([targetPath, { isNewFile, diffs }]) => (
+
+
+ {targetPath}
+ {isNewFile ? ' (new file)' : ''}
+ {diffs.length > 1
+ ? ` Ā· ${diffs.length} changes from different patches`
+ : ''}
+
+ {diffs.map((diff, hunkIndex) => (
+
+ ))}
+
+ ),
+ );
+ })()}
+
+
+
+ items={memoryPatchActionItems}
+ onSelect={handleSelectMemoryPatchAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? 'ā ' : 'ā '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 3bf48259fe..3608f00e3d 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -104,7 +104,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js');
-
+vi.mock('./ListeningIndicator.js', () => ({
+ ListeningIndicator: vi.fn(({ color }) => ~~~ ),
+}));
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal();
@@ -348,7 +350,7 @@ describe('InputPrompt', () => {
visualToLogicalMap: [[0, 0]],
visualToTransformedMap: [0],
transformationsByLine: [],
- getOffset: vi.fn().mockReturnValue(0),
+ getOffset: vi.fn().mockImplementation(() => mockBuffer.cursor[1]),
pastedContent: {},
} as unknown as TextBuffer;
@@ -4979,7 +4981,6 @@ describe('InputPrompt', () => {
);
// Initially not recording
- expect(lastFrame()).not.toContain('Listening...');
expect(lastFrame()).toContain('š¤ >');
expect(lastFrame()).toContain(
'Type your message or space to talk (Esc to exit)',
@@ -4990,11 +4991,6 @@ describe('InputPrompt', () => {
stdin.write(' ');
});
- // Now should show listening
- await waitFor(() => {
- expect(lastFrame()).toContain('Listening...');
- });
-
unmount();
});
@@ -5002,7 +4998,7 @@ describe('InputPrompt', () => {
await act(async () => {
mockBuffer.setText('');
});
- const { stdin, unmount, lastFrame } = await renderWithProviders(
+ const { stdin, unmount } = await renderWithProviders(
,
{
uiState: { isVoiceModeEnabled: true } as UIState,
@@ -5016,25 +5012,18 @@ describe('InputPrompt', () => {
await act(async () => {
stdin.write(' ');
});
- await waitFor(() => {
- expect(lastFrame()).toContain('Listening...');
- });
// Stop recording
await act(async () => {
stdin.write(' ');
});
- await waitFor(() => {
- expect(lastFrame()).not.toContain('Listening...');
- expect(lastFrame()).toContain('š¤ >');
- });
unmount();
});
it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
await act(async () => {
- mockBuffer.setText('some existing text');
+ mockBuffer.setText('First turn.');
});
const { stdin, unmount, lastFrame } = await renderWithProviders(
,
@@ -5048,17 +5037,13 @@ describe('InputPrompt', () => {
// Should show voice mode prefix even if buffer is not empty
expect(lastFrame()).toContain('š¤ >');
- expect(lastFrame()).toContain('some existing text');
+ expect(lastFrame()).toContain('First turn.');
// Press space to start recording again
await act(async () => {
stdin.write(' ');
});
- await waitFor(() => {
- expect(lastFrame()).toContain('Listening...');
- });
-
unmount();
});
@@ -5066,7 +5051,7 @@ describe('InputPrompt', () => {
await act(async () => {
mockBuffer.setText('');
});
- const { stdin, unmount, lastFrame } = await renderWithProviders(
+ const { stdin, unmount } = await renderWithProviders(
,
{
uiState: { isVoiceModeEnabled: false } as UIState,
@@ -5082,7 +5067,6 @@ describe('InputPrompt', () => {
});
// Should NOT show listening, instead should call handleInput which handles space
- expect(lastFrame()).not.toContain('Listening...');
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
@@ -5114,17 +5098,15 @@ describe('InputPrompt', () => {
);
});
await waitFor(() => {
- expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 'end');
+ expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 13);
});
- // Emit turnComplete (Gemini Live starts over after this)
+ // turnComplete advances the baseline; next turn appends after it
await act(async () => {
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
'turnComplete',
);
});
-
- // Emit second part (Gemini Live sends new turn text starting from empty)
await act(async () => {
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
'transcription',
@@ -5132,10 +5114,9 @@ describe('InputPrompt', () => {
);
});
await waitFor(() => {
- // Should have appended 'world' to the baseline 'initial hello'
expect(mockBuffer.setText).toHaveBeenCalledWith(
'initial hello world',
- 'end',
+ 19,
);
});
@@ -5172,13 +5153,48 @@ describe('InputPrompt', () => {
await waitFor(() => {
expect(mockBuffer.setText).toHaveBeenCalledWith(
'First turn. Second turn.',
- 'end',
+ 24,
);
});
unmount();
});
+ it('should insert transcription at cursor position when buffer has text before and after (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('hello world');
+ mockBuffer.cursor = [0, 5]; // cursor after 'hello'
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ await act(async () => {
+ stdin.write(' ');
+ });
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'there',
+ );
+ });
+
+ // 'hello'(5) + ' '(1) + 'there'(5) = cursor at 11; ' world' preserved after
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'hello there world',
+ 11,
+ );
+ });
+ unmount();
+ });
+
describe('push-to-talk', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -5211,19 +5227,17 @@ describe('InputPrompt', () => {
// Should insert space optimistically
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
- expect(lastFrame()).not.toContain('Listening...');
// Advance timer past HOLD_DELAY_MS
await act(async () => {
vi.advanceTimersByTime(700);
});
- expect(lastFrame()).not.toContain('Listening...');
unmount();
});
it('should start recording on hold (simulated by repeat spaces)', async () => {
- const { stdin, unmount, lastFrame } = await renderWithProviders(
+ const { stdin, unmount } = await renderWithProviders(
,
{
uiState: { isVoiceModeEnabled: true } as UIState,
@@ -5247,8 +5261,6 @@ describe('InputPrompt', () => {
await waitFor(() => {
// Should have backspaced the optimistic space
expect(mockBuffer.backspace).toHaveBeenCalled();
- // Should show listening
- expect(lastFrame()).toContain('Listening...');
});
unmount();
@@ -5271,31 +5283,18 @@ describe('InputPrompt', () => {
stdin.write(' ');
});
- // Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
- await waitFor(
- () => {
- expect(lastFrame()).toContain('Listening...');
- },
- { interval: 10 },
- );
-
// Simulate heartbeat (held key) - send space first to reset timer, then advance
await act(async () => {
stdin.write(' ');
vi.advanceTimersByTime(100);
});
- expect(lastFrame()).toContain('š¤ >');
- expect(lastFrame()).toContain('Listening...');
+ expect(lastFrame()).toContain('~~~ >');
// Stop heartbeat (release)
await act(async () => {
vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
});
- await waitFor(() => {
- expect(lastFrame()).not.toContain('Listening...');
- });
-
unmount();
});
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 0e823d77a4..67fefe0656 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -23,6 +23,7 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
+import { ListeningIndicator } from './ListeningIndicator.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -1800,7 +1801,12 @@ export const InputPrompt: React.FC = ({
useBackgroundColor={useBackgroundColor}
>
- {isVoiceModeEnabled && š¤ }
+ {isVoiceModeEnabled &&
+ (isRecording ? (
+
+ ) : (
+ š¤
+ ))}
= ({
)}{' '}
- {isRecording && (
-
- Listening...
-
- )}
- {buffer.text.length === 0 && !isRecording ? (
+ {buffer.text.length === 0 ? (
effectivePlaceholder ? (
showCursor ? (
= ({
+ color,
+}) => {
+ const [tick, setTick] = useState(0);
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+
+ useEffect(() => {
+ if (isScreenReaderEnabled) return;
+ const timer = setInterval(() => setTick((t) => t + 1), FRAME_INTERVAL_MS);
+ return () => clearInterval(timer);
+ }, [isScreenReaderEnabled]);
+
+ if (isScreenReaderEnabled) {
+ return Listening... ;
+ }
+
+ // Generate 3 bars for the wave
+ const bars = Array.from({ length: 3 }).map((_, i) => {
+ // Sine wave calculation to map to our 8 block characters (0-7)
+ const phase = tick * ANIMATION_SPEED + i * BAR_PHASE_OFFSET;
+ const height = Math.floor((Math.sin(phase) + 1) * MAX_HEIGHT_MULTIPLIER);
+ return WAVE_CHARS[Math.max(0, Math.min(7, height))] ?? ' ';
+ });
+
+ return {bars.join('')} ;
+};
diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx
index 2bc6ee27bc..0aea3236ce 100644
--- a/packages/cli/src/ui/components/MainContent.test.tsx
+++ b/packages/cli/src/ui/components/MainContent.test.tsx
@@ -15,6 +15,20 @@ import { Box, Text } from 'ink';
import { act, useState, type JSX } from 'react';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ validatePlanPath: vi
+ .fn()
+ .mockResolvedValue('Storage must be initialized before use'),
+ validatePlanContent: vi
+ .fn()
+ .mockResolvedValue('Storage must be initialized before use'),
+ };
+});
import {
UIStateContext,
useUIState,
@@ -672,9 +686,15 @@ describe('MainContent', () => {
}),
);
- const { lastFrame, unmount } = await renderWithProviders(, {
- uiState: uiState as Partial,
- config: makeFakeConfig({ useAlternateBuffer: false }),
+ let lastFrame!: () => string;
+ let unmount!: () => void;
+ await act(async () => {
+ const res = await renderWithProviders(, {
+ uiState: uiState as Partial,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ });
+ lastFrame = res.lastFrame;
+ unmount = res.unmount;
});
await waitFor(() => {
@@ -683,6 +703,8 @@ describe('MainContent', () => {
expect(output).not.toContain('Hidden content');
// The output should contain the confirmation header
expect(output).toContain('Ready to start implementation?');
+ // Wait for the async error message to appear
+ expect(output).toContain('File not found: /path/to/plan');
});
// Snapshot will reveal if there are extra blank lines
diff --git a/packages/cli/src/ui/components/SkillInboxDialog.tsx b/packages/cli/src/ui/components/SkillInboxDialog.tsx
deleted file mode 100644
index 509022c4ff..0000000000
--- a/packages/cli/src/ui/components/SkillInboxDialog.tsx
+++ /dev/null
@@ -1,876 +0,0 @@
-/**
- * @license
- * Copyright 2026 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import * as path from 'node:path';
-import type React from 'react';
-import { useState, useMemo, useCallback, useEffect } from 'react';
-import { Box, Text, useStdout } from 'ink';
-import { theme } from '../semantic-colors.js';
-import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../key/keyMatchers.js';
-import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
-import { BaseSelectionList } from './shared/BaseSelectionList.js';
-import type { SelectionListItem } from '../hooks/useSelectionList.js';
-import { DialogFooter } from './shared/DialogFooter.js';
-import { DiffRenderer } from './messages/DiffRenderer.js';
-import {
- type Config,
- type InboxSkill,
- type InboxPatch,
- type InboxSkillDestination,
- getErrorMessage,
- listInboxSkills,
- listInboxPatches,
- moveInboxSkill,
- dismissInboxSkill,
- applyInboxPatch,
- dismissInboxPatch,
- isProjectSkillPatchTarget,
-} from '@google/gemini-cli-core';
-
-type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
-
-type InboxItem =
- | { type: 'skill'; skill: InboxSkill }
- | { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
- | { type: 'header'; label: string };
-
-interface DestinationChoice {
- destination: InboxSkillDestination;
- label: string;
- description: string;
-}
-
-interface PatchAction {
- action: 'apply' | 'dismiss';
- label: string;
- description: string;
-}
-
-const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
- {
- destination: 'global',
- label: 'Global',
- description: '~/.gemini/skills ā available in all projects',
- },
- {
- destination: 'project',
- label: 'Project',
- description: '.gemini/skills ā available in this workspace',
- },
-];
-
-interface SkillPreviewAction {
- action: 'move' | 'dismiss';
- label: string;
- description: string;
-}
-
-const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
- {
- action: 'move',
- label: 'Move',
- description: 'Choose where to install this skill',
- },
- {
- action: 'dismiss',
- label: 'Dismiss',
- description: 'Delete from inbox',
- },
-];
-
-const PATCH_ACTION_CHOICES: PatchAction[] = [
- {
- action: 'apply',
- label: 'Apply',
- description: 'Apply patch and delete from inbox',
- },
- {
- action: 'dismiss',
- label: 'Dismiss',
- description: 'Delete from inbox without applying',
- },
-];
-
-function normalizePathForUi(filePath: string): string {
- return path.posix.normalize(filePath.replaceAll('\\', '/'));
-}
-
-function getPathBasename(filePath: string): string {
- const normalizedPath = normalizePathForUi(filePath);
- const basename = path.posix.basename(normalizedPath);
- return basename === '.' ? filePath : basename;
-}
-
-async function patchTargetsProjectSkills(
- patch: InboxPatch,
- config: Config,
-): Promise {
- const entryTargetsProjectSkills = await Promise.all(
- patch.entries.map((entry) =>
- isProjectSkillPatchTarget(entry.targetPath, config),
- ),
- );
- return entryTargetsProjectSkills.some(Boolean);
-}
-
-/**
- * Derives a bracketed origin tag from a skill file path,
- * matching the existing [Built-in] convention in SkillsList.
- */
-function getSkillOriginTag(filePath: string): string {
- const normalizedPath = normalizePathForUi(filePath);
-
- if (normalizedPath.includes('/bundle/')) {
- return 'Built-in';
- }
- if (normalizedPath.includes('/extensions/')) {
- return 'Extension';
- }
- if (normalizedPath.includes('/.gemini/skills/')) {
- const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
- .filter((homeDir): homeDir is string => Boolean(homeDir))
- .map(normalizePathForUi);
- if (
- homeDirs.some((homeDir) =>
- normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
- )
- ) {
- return 'Global';
- }
- return 'Workspace';
- }
- return '';
-}
-
-/**
- * Creates a unified diff string representing a new file.
- */
-function newFileDiff(filename: string, content: string): string {
- const lines = content.split('\n');
- const hunkLines = lines.map((l) => `+${l}`).join('\n');
- return [
- `--- /dev/null`,
- `+++ ${filename}`,
- `@@ -0,0 +1,${lines.length} @@`,
- hunkLines,
- ].join('\n');
-}
-
-function formatDate(isoString: string): string {
- try {
- const date = new Date(isoString);
- return date.toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- });
- } catch {
- return isoString;
- }
-}
-
-interface SkillInboxDialogProps {
- config: Config;
- onClose: () => void;
- onReloadSkills: () => Promise;
-}
-
-export const SkillInboxDialog: React.FC = ({
- config,
- onClose,
- onReloadSkills,
-}) => {
- const keyMatchers = useKeyMatchers();
- const { stdout } = useStdout();
- const terminalWidth = stdout?.columns ?? 80;
- const isTrustedFolder = config.isTrustedFolder();
- const [phase, setPhase] = useState('list');
- const [items, setItems] = useState([]);
- const [loading, setLoading] = useState(true);
- const [selectedItem, setSelectedItem] = useState(null);
- const [feedback, setFeedback] = useState<{
- text: string;
- isError: boolean;
- } | null>(null);
-
- // Load inbox skills and patches on mount
- useEffect(() => {
- let cancelled = false;
- void (async () => {
- try {
- const [skills, patches] = await Promise.all([
- listInboxSkills(config),
- listInboxPatches(config),
- ]);
- const patchItems = await Promise.all(
- patches.map(async (patch): Promise => {
- let targetsProjectSkills = false;
- try {
- targetsProjectSkills = await patchTargetsProjectSkills(
- patch,
- config,
- );
- } catch {
- targetsProjectSkills = false;
- }
-
- return {
- type: 'patch',
- patch,
- targetsProjectSkills,
- };
- }),
- );
- if (!cancelled) {
- const combined: InboxItem[] = [
- ...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
- ...patchItems,
- ];
- setItems(combined);
- setLoading(false);
- }
- } catch {
- if (!cancelled) {
- setItems([]);
- setLoading(false);
- }
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [config]);
-
- const getItemKey = useCallback(
- (item: InboxItem): string =>
- item.type === 'skill'
- ? `skill:${item.skill.dirName}`
- : item.type === 'patch'
- ? `patch:${item.patch.fileName}`
- : `header:${item.label}`,
- [],
- );
-
- const listItems: Array> = useMemo(() => {
- const skills = items.filter((i) => i.type === 'skill');
- const patches = items.filter((i) => i.type === 'patch');
- const result: Array> = [];
-
- // Only show section headers when both types are present
- const showHeaders = skills.length > 0 && patches.length > 0;
-
- if (showHeaders) {
- const header: InboxItem = { type: 'header', label: 'New Skills' };
- result.push({
- key: 'header:new-skills',
- value: header,
- disabled: true,
- hideNumber: true,
- });
- }
- for (const item of skills) {
- result.push({ key: getItemKey(item), value: item });
- }
-
- if (showHeaders) {
- const header: InboxItem = { type: 'header', label: 'Skill Updates' };
- result.push({
- key: 'header:skill-updates',
- value: header,
- disabled: true,
- hideNumber: true,
- });
- }
- for (const item of patches) {
- result.push({ key: getItemKey(item), value: item });
- }
-
- return result;
- }, [items, getItemKey]);
-
- const destinationItems: Array> = useMemo(
- () =>
- SKILL_DESTINATION_CHOICES.map((choice) => {
- if (choice.destination === 'project' && !isTrustedFolder) {
- return {
- key: choice.destination,
- value: {
- ...choice,
- description:
- '.gemini/skills ā unavailable until this workspace is trusted',
- },
- disabled: true,
- };
- }
-
- return {
- key: choice.destination,
- value: choice,
- };
- }),
- [isTrustedFolder],
- );
-
- const selectedPatchTargetsProjectSkills = useMemo(() => {
- if (!selectedItem || selectedItem.type !== 'patch') {
- return false;
- }
-
- return selectedItem.targetsProjectSkills;
- }, [selectedItem]);
-
- const patchActionItems: Array> = useMemo(
- () =>
- PATCH_ACTION_CHOICES.map((choice) => {
- if (
- choice.action === 'apply' &&
- selectedPatchTargetsProjectSkills &&
- !isTrustedFolder
- ) {
- return {
- key: choice.action,
- value: {
- ...choice,
- description:
- '.gemini/skills ā unavailable until this workspace is trusted',
- },
- disabled: true,
- };
- }
-
- return {
- key: choice.action,
- value: choice,
- };
- }),
- [isTrustedFolder, selectedPatchTargetsProjectSkills],
- );
-
- const skillPreviewItems: Array> =
- useMemo(
- () =>
- SKILL_PREVIEW_CHOICES.map((choice) => ({
- key: choice.action,
- value: choice,
- })),
- [],
- );
-
- const handleSelectItem = useCallback((item: InboxItem) => {
- setSelectedItem(item);
- setFeedback(null);
- setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
- }, []);
-
- const removeItem = useCallback(
- (item: InboxItem) => {
- setItems((prev) =>
- prev.filter((i) => getItemKey(i) !== getItemKey(item)),
- );
- },
- [getItemKey],
- );
-
- const handleSkillPreviewAction = useCallback(
- (choice: SkillPreviewAction) => {
- if (!selectedItem || selectedItem.type !== 'skill') return;
-
- if (choice.action === 'move') {
- setFeedback(null);
- setPhase('skill-action');
- return;
- }
-
- // Dismiss
- setFeedback(null);
- const skill = selectedItem.skill;
- void (async () => {
- try {
- const result = await dismissInboxSkill(config, skill.dirName);
- setFeedback({ text: result.message, isError: !result.success });
- if (result.success) {
- removeItem(selectedItem);
- setSelectedItem(null);
- setPhase('list');
- }
- } catch (error) {
- setFeedback({
- text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
- isError: true,
- });
- }
- })();
- },
- [config, selectedItem, removeItem],
- );
-
- const handleSelectDestination = useCallback(
- (choice: DestinationChoice) => {
- if (!selectedItem || selectedItem.type !== 'skill') return;
- const skill = selectedItem.skill;
-
- if (choice.destination === 'project' && !config.isTrustedFolder()) {
- setFeedback({
- text: 'Project skills are unavailable until this workspace is trusted.',
- isError: true,
- });
- return;
- }
-
- setFeedback(null);
-
- void (async () => {
- try {
- const result = await moveInboxSkill(
- config,
- skill.dirName,
- choice.destination,
- );
-
- setFeedback({ text: result.message, isError: !result.success });
-
- if (!result.success) {
- return;
- }
-
- removeItem(selectedItem);
- setSelectedItem(null);
- setPhase('list');
-
- try {
- await onReloadSkills();
- } catch (error) {
- setFeedback({
- text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
- isError: true,
- });
- }
- } catch (error) {
- setFeedback({
- text: `Failed to install skill: ${getErrorMessage(error)}`,
- isError: true,
- });
- }
- })();
- },
- [config, selectedItem, onReloadSkills, removeItem],
- );
-
- const handleSelectPatchAction = useCallback(
- (choice: PatchAction) => {
- if (!selectedItem || selectedItem.type !== 'patch') return;
- const patch = selectedItem.patch;
-
- if (
- choice.action === 'apply' &&
- !config.isTrustedFolder() &&
- selectedItem.targetsProjectSkills
- ) {
- setFeedback({
- text: 'Project skill patches are unavailable until this workspace is trusted.',
- isError: true,
- });
- return;
- }
-
- setFeedback(null);
-
- void (async () => {
- try {
- let result: { success: boolean; message: string };
- if (choice.action === 'apply') {
- result = await applyInboxPatch(config, patch.fileName);
- } else {
- result = await dismissInboxPatch(config, patch.fileName);
- }
-
- setFeedback({ text: result.message, isError: !result.success });
-
- if (!result.success) {
- return;
- }
-
- removeItem(selectedItem);
- setSelectedItem(null);
- setPhase('list');
-
- if (choice.action === 'apply') {
- try {
- await onReloadSkills();
- } catch (error) {
- setFeedback({
- text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
- isError: true,
- });
- }
- }
- } catch (error) {
- const operation =
- choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
- setFeedback({
- text: `Failed to ${operation}: ${getErrorMessage(error)}`,
- isError: true,
- });
- }
- })();
- },
- [config, selectedItem, onReloadSkills, removeItem],
- );
-
- useKeypress(
- (key) => {
- if (keyMatchers[Command.ESCAPE](key)) {
- if (phase === 'skill-action') {
- setPhase('skill-preview');
- setFeedback(null);
- } else if (phase !== 'list') {
- setPhase('list');
- setSelectedItem(null);
- setFeedback(null);
- } else {
- onClose();
- }
- return true;
- }
- return false;
- },
- { isActive: true, priority: true },
- );
-
- if (loading) {
- return (
-
- Loading inboxā¦
-
- );
- }
-
- if (items.length === 0 && !feedback) {
- return (
-
- Memory Inbox
-
- No items in inbox.
-
-
-
- );
- }
-
- // Border + paddingX account for 6 chars of width
- const contentWidth = terminalWidth - 6;
-
- return (
-
- {phase === 'list' && (
- <>
-
- Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
-
-
- Extracted from past sessions. Select one to review.
-
-
-
-
- items={listItems}
- onSelect={handleSelectItem}
- isFocused={true}
- showNumbers={false}
- showScrollArrows={true}
- maxItemsToShow={8}
- renderItem={(item, { titleColor }) => {
- if (item.value.type === 'header') {
- return (
-
-
- {item.value.label}
-
-
- );
- }
- if (item.value.type === 'skill') {
- const skill = item.value.skill;
- return (
-
-
- {skill.name}
-
-
-
- {skill.description}
-
- {skill.extractedAt && (
-
- {' Ā· '}
- {formatDate(skill.extractedAt)}
-
- )}
-
-
- );
- }
- const patch = item.value.patch;
- const fileNames = patch.entries.map((e) =>
- getPathBasename(e.targetPath),
- );
- const origin = getSkillOriginTag(
- patch.entries[0]?.targetPath ?? '',
- );
- return (
-
-
-
- {patch.name}
-
- {origin && (
-
- {` [${origin}]`}
-
- )}
-
-
-
- {fileNames.join(', ')}
-
- {patch.extractedAt && (
-
- {' Ā· '}
- {formatDate(patch.extractedAt)}
-
- )}
-
-
- );
- }}
- />
-
-
- {feedback && (
-
-
- {feedback.isError ? 'ā ' : 'ā '}
- {feedback.text}
-
-
- )}
-
-
- >
- )}
-
- {phase === 'skill-preview' && selectedItem?.type === 'skill' && (
- <>
- {selectedItem.skill.name}
-
- Review new skill before installing.
-
-
- {selectedItem.skill.content && (
-
-
- SKILL.md
-
-
-
- )}
-
-
-
- items={skillPreviewItems}
- onSelect={handleSkillPreviewAction}
- isFocused={true}
- showNumbers={true}
- renderItem={(item, { titleColor }) => (
-
-
- {item.value.label}
-
-
- {item.value.description}
-
-
- )}
- />
-
-
- {feedback && (
-
-
- {feedback.isError ? 'ā ' : 'ā '}
- {feedback.text}
-
-
- )}
-
-
- >
- )}
-
- {phase === 'skill-action' && selectedItem?.type === 'skill' && (
- <>
- Move "{selectedItem.skill.name}"
-
- Choose where to install this skill.
-
-
-
-
- items={destinationItems}
- onSelect={handleSelectDestination}
- isFocused={true}
- showNumbers={true}
- renderItem={(item, { titleColor }) => (
-
-
- {item.value.label}
-
-
- {item.value.description}
-
-
- )}
- />
-
-
- {feedback && (
-
-
- {feedback.isError ? 'ā ' : 'ā '}
- {feedback.text}
-
-
- )}
-
-
- >
- )}
-
- {phase === 'patch-preview' && selectedItem?.type === 'patch' && (
- <>
- {selectedItem.patch.name}
-
-
- Review changes before applying.
-
- {(() => {
- const origin = getSkillOriginTag(
- selectedItem.patch.entries[0]?.targetPath ?? '',
- );
- return origin ? (
- {` [${origin}]`}
- ) : null;
- })()}
-
-
-
- {selectedItem.patch.entries.map((entry, index) => (
-
-
- {entry.targetPath}
-
-
-
- ))}
-
-
-
-
- items={patchActionItems}
- onSelect={handleSelectPatchAction}
- isFocused={true}
- showNumbers={true}
- renderItem={(item, { titleColor }) => (
-
-
- {item.value.label}
-
-
- {item.value.description}
-
-
- )}
- />
-
-
- {feedback && (
-
-
- {feedback.isError ? 'ā ' : 'ā '}
- {feedback.text}
-
-
- )}
-
-
- >
- )}
-
- );
-};
diff --git a/packages/cli/src/ui/components/ThemeDialog.constants.ts b/packages/cli/src/ui/components/ThemeDialog.constants.ts
new file mode 100644
index 0000000000..dd13060323
--- /dev/null
+++ b/packages/cli/src/ui/components/ThemeDialog.constants.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** The fraction of the dialog width allocated to the selection (left) pane. */
+export const SELECTION_PANE_WIDTH_PERCENTAGE = 0.45;
+
+/** The fraction of the dialog width allocated to the preview (right) pane. */
+export const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
+
+/**
+ * A safety margin to prevent text from touching the preview pane border.
+ * Note: This is specific to the ThemeDialog layout and is unrelated to
+ * SHELL_WIDTH_FRACTION in AppContainer.
+ */
+export const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
+
+/**
+ * Combined horizontal padding from the dialog and preview pane used
+ * to calculate available width for the code preview.
+ */
+export const TOTAL_HORIZONTAL_PADDING = 4;
+
+/** Padding for the dialog container. */
+export const DIALOG_PADDING = 2;
+
+/** Fixed vertical space taken by preview pane elements (title, borders, margins). */
+export const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
+
+/** Height of the tab/scope selection hint at the bottom. */
+export const TAB_TO_SELECT_HEIGHT = 2;
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index 49683fd950..5f037d6ad7 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -77,6 +77,16 @@ function generateThemeItem(
};
}
+import {
+ DIALOG_PADDING,
+ PREVIEW_PANE_FIXED_VERTICAL_SPACE,
+ PREVIEW_PANE_WIDTH_PERCENTAGE,
+ PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
+ SELECTION_PANE_WIDTH_PERCENTAGE,
+ TAB_TO_SELECT_HEIGHT,
+ TOTAL_HORIZONTAL_PADDING,
+} from './ThemeDialog.constants.js';
+
export function ThemeDialog({
onSelect,
onCancel,
@@ -190,14 +200,6 @@ export function ThemeDialog({
settings,
);
- // Constants for calculating preview pane layout.
- // These values are based on the JSX structure below.
- const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
- // A safety margin to prevent text from touching the border.
- // This is a complete hack unrelated to the 0.9 used in App.tsx
- const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
- // Combined horizontal padding from the dialog and preview pane.
- const TOTAL_HORIZONTAL_PADDING = 4;
const colorizeCodeWidth = Math.max(
Math.floor(
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
@@ -207,9 +209,7 @@ export function ThemeDialog({
1,
);
- const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
- const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
@@ -224,10 +224,6 @@ export function ThemeDialog({
totalLeftHandSideHeight -= DIALOG_PADDING;
}
- // Vertical space taken by elements other than the two code blocks in the preview pane.
- // Includes "Preview" title, borders, and margin between blocks.
- const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
-
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math.max(
availableTerminalHeight,
@@ -252,6 +248,9 @@ export function ThemeDialog({
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
DEFAULT_THEME;
+ const leftColumnWidth = `${SELECTION_PANE_WIDTH_PERCENTAGE * 100}%`;
+ const rightColumnWidth = `${PREVIEW_PANE_WIDTH_PERCENTAGE * 100}%`;
+
return (
{/* Left Column: Selection */}
-
+
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
@@ -340,7 +339,7 @@ export function ThemeDialog({
{/* Right Column: Preview */}
-
+
Preview
diff --git a/packages/cli/src/ui/components/VoiceModelDialog.test.tsx b/packages/cli/src/ui/components/VoiceModelDialog.test.tsx
new file mode 100644
index 0000000000..7ec081b032
--- /dev/null
+++ b/packages/cli/src/ui/components/VoiceModelDialog.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { VoiceModelDialog } from './VoiceModelDialog.js';
+import { act } from 'react';
+import { waitFor } from '../../test-utils/async.js';
+import { SettingScope } from '../../config/settings.js';
+
+vi.mock('@google/gemini-cli-core', async () => {
+ const actual = await vi.importActual('@google/gemini-cli-core');
+ return {
+ ...actual,
+ isBinaryAvailable: vi.fn().mockReturnValue(true),
+ WhisperModelManager: vi.fn().mockImplementation(() => ({
+ isModelInstalled: vi.fn().mockReturnValue(false),
+ on: vi.fn(),
+ off: vi.fn(),
+ downloadModel: vi.fn(),
+ })),
+ };
+});
+
+describe('VoiceModelDialog', () => {
+ it('should display a privacy warning when Gemini Live API (Cloud) is selected', async () => {
+ const onClose = vi.fn();
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ const frame = lastFrame();
+ expect(frame).toContain('Gemini Live API (Cloud)');
+ expect(frame).toContain('When using the Gemini Live backend');
+ });
+
+ it('should NOT display a privacy warning when Whisper (Local) is highlighted', async () => {
+ const onClose = vi.fn();
+ const { lastFrame, waitUntilReady, stdin } = await renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ // Verify warning is present for default (Gemini Live)
+ expect(lastFrame()).toContain('When using the Gemini Live backend');
+
+ // Arrow Down to highlight Whisper
+ await act(async () => {
+ stdin.write('\u001b[B');
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Whisper (Local)');
+ expect(frame).not.toContain('When using the Gemini Live backend');
+ });
+ });
+
+ it('should update settings and close dialog when a backend is selected', async () => {
+ const onClose = vi.fn();
+ const settings = createMockSettings();
+ const setValueSpy = vi.spyOn(settings, 'setValue');
+
+ const { waitUntilReady, stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+
+ // Select Gemini Live (it's already highlighted, just press Enter)
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(setValueSpy).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'experimental.voice.backend',
+ 'gemini-live',
+ );
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/VoiceModelDialog.tsx b/packages/cli/src/ui/components/VoiceModelDialog.tsx
index f340a5ccf4..e882c89235 100644
--- a/packages/cli/src/ui/components/VoiceModelDialog.tsx
+++ b/packages/cli/src/ui/components/VoiceModelDialog.tsx
@@ -18,6 +18,7 @@ import {
type WhisperModelProgress,
} from '@google/gemini-cli-core';
import { CliSpinner } from './CliSpinner.js';
+import { WarningMessage } from './messages/WarningMessage.js';
interface VoiceModelDialogProps {
onClose: () => void;
@@ -68,6 +69,9 @@ export function VoiceModelDialog({
const currentWhisperModel =
settings.merged.experimental.voice?.whisperModel ?? 'ggml-base.en.bin';
+ const [highlightedBackend, setHighlightedBackend] =
+ useState(currentBackend);
+
const handleKeypress = useCallback(
(key: Key) => {
if (key.name === 'escape') {
@@ -101,6 +105,10 @@ export function VoiceModelDialog({
[setSetting, onClose],
);
+ const handleBackendHighlight = useCallback((value: string) => {
+ setHighlightedBackend(value);
+ }, []);
+
const handleWhisperModelSelect = useCallback(
async (modelName: string) => {
if (modelManager.isModelInstalled(modelName)) {
@@ -203,14 +211,22 @@ export function VoiceModelDialog({
) : (
-
+
{view === 'backend' ? (
-
+ <>
+
+ {highlightedBackend === 'gemini-live' && (
+
+
+
+ )}
+ >
) : (
renders a ToolConfirmationQueue without an extra line whe
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā®
ā Ready to start implementation? ā
ā ā
-ā Error reading plan: Storage must be initialized before use ā
+ā Error reading plan: File not found: /path/to/plan ā
ā°āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāÆ
"
`;
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index 94584879f9..dbf1533d9d 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -368,6 +368,39 @@ describe('', () => {
unmount();
});
+ it('renders update_topic in the middle of other tools', async () => {
+ const toolCalls = [
+ createToolCall({
+ callId: 'tool-1',
+ name: 'read_file',
+ status: CoreToolCallStatus.Success,
+ }),
+ createToolCall({
+ callId: 'topic-tool-middle',
+ name: UPDATE_TOPIC_TOOL_NAME,
+ args: {
+ [TOPIC_PARAM_TITLE]: 'Middle Topic',
+ },
+ }),
+ createToolCall({
+ callId: 'tool-2',
+ name: 'write_file',
+ status: CoreToolCallStatus.Success,
+ }),
+ ];
+ const item = createItem(toolCalls);
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: baseMockConfig,
+ settings: fullVerbositySettings,
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot('update_topic_middle');
+ unmount();
+ });
+
it('renders with limited terminal height', async () => {
const toolCalls = [
createToolCall({
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index f71f3e7800..3ca1fad658 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -192,6 +192,9 @@ export const ToolGroupMessage: React.FC = ({
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
+ const prevIsTopic =
+ prevGroup && !Array.isArray(prevGroup) && isTopicTool(prevGroup.name);
+
const nextGroup = !isLast ? groupedTools[i + 1] : null;
const nextIsCompact =
nextGroup &&
@@ -226,7 +229,7 @@ export const ToolGroupMessage: React.FC = ({
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
- : prevIsCompact);
+ : prevIsCompact || prevIsTopic);
const showClosingBorder =
!isCompact &&
@@ -363,6 +366,8 @@ export const ToolGroupMessage: React.FC = ({
prevGroup &&
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
+ const prevIsTopic =
+ prevGroup && !Array.isArray(prevGroup) && isTopicTool(prevGroup.name);
const nextGroup = !isLast ? groupedTools[index + 1] : null;
const nextIsCompact =
@@ -379,7 +384,7 @@ export const ToolGroupMessage: React.FC = ({
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
- : prevIsCompact);
+ : prevIsCompact || prevIsTopic);
const showClosingBorder =
!isCompact &&
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
index b0d33feebd..e0caedef9b 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
@@ -141,6 +141,22 @@ exports[` > Golden Snapshots > renders two tool groups where
"
`;
+exports[` > Golden Snapshots > renders update_topic in the middle of other tools > update_topic_middle 1`] = `
+"āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā®
+ā ā read_file A tool for testing ā
+ā ā
+ā Test result ā
+ā°āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāÆ
+ Middle Topic
+
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā®
+ā ā write_file A tool for testing ā
+ā ā
+ā Test result ā
+ā°āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāÆ
+"
+`;
+
exports[` > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
" Testing Topic: This is the description
"
diff --git a/packages/cli/src/ui/hooks/useAgentStream.test.tsx b/packages/cli/src/ui/hooks/useAgentStream.test.tsx
index 53bb512504..1136a3592e 100644
--- a/packages/cli/src/ui/hooks/useAgentStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useAgentStream.test.tsx
@@ -202,6 +202,6 @@ describe('useAgentStream', () => {
});
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
- expect(mockOnCancelSubmit).toHaveBeenCalledWith(false);
+ expect(mockOnCancelSubmit).toHaveBeenCalledWith(false, true);
});
});
diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts
index 926ba7cc7c..aea7b76ba5 100644
--- a/packages/cli/src/ui/hooks/useAgentStream.ts
+++ b/packages/cli/src/ui/hooks/useAgentStream.ts
@@ -36,11 +36,15 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js';
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
+import { useKeypress } from './useKeypress.js';
export interface UseAgentStreamOptions {
agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem'];
- onCancelSubmit: (shouldRestorePrompt?: boolean) => void;
+ onCancelSubmit: (
+ shouldRestorePrompt?: boolean,
+ clearBuffer?: boolean,
+ ) => void;
isShellFocused?: boolean;
logger?: Logger | null;
}
@@ -120,13 +124,16 @@ export const useAgentStream = ({
}
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
- const cancelOngoingRequest = useCallback(async () => {
- if (agent) {
- await agent.abort();
- setStreamingState(StreamingState.Idle);
- onCancelSubmit(false);
- }
- }, [agent, onCancelSubmit]);
+ const cancelOngoingRequest = useCallback(
+ async (clearBuffer: boolean = true) => {
+ if (agent) {
+ await agent.abort();
+ setStreamingState(StreamingState.Idle);
+ onCancelSubmit(false, clearBuffer);
+ }
+ },
+ [agent, onCancelSubmit],
+ );
// TODO: Support native handleApprovalModeChange for Plan Mode
const handleApprovalModeChange = useCallback(
@@ -322,6 +329,21 @@ export const useAgentStream = ({
return () => unsubscribe?.();
}, [agent, handleEvent]);
+ useKeypress(
+ (key) => {
+ if (key.name === 'escape' && !isShellFocused) {
+ void cancelOngoingRequest(false);
+ return true;
+ }
+ return false;
+ },
+ {
+ isActive:
+ streamingState === StreamingState.Responding ||
+ streamingState === StreamingState.WaitingForConfirmation,
+ },
+ );
+
const submitQuery = useCallback(
async (
query: Part[] | string,
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index d6c68ec880..a5e5ea4706 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -805,7 +805,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
'prompt-id-2',
undefined,
- false,
expectedMergedResponse,
);
});
@@ -1532,7 +1531,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
'prompt-id-4',
undefined,
- false,
toolCallResponseParts,
);
});
@@ -1637,7 +1635,7 @@ describe('useGeminiStream', () => {
simulateEscapeKeyPress();
- expect(cancelSubmitSpy).toHaveBeenCalledWith(false);
+ expect(cancelSubmitSpy).toHaveBeenCalledWith(false, false);
});
it('should call setShellInputFocused(false) when escape is pressed', async () => {
@@ -2027,7 +2025,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'/my-custom-command',
);
@@ -2056,7 +2053,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'/emptycmd',
);
});
@@ -2077,7 +2073,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'// This is a line comment',
);
});
@@ -2098,7 +2093,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'/* This is a block comment */',
);
});
@@ -3058,7 +3052,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined,
- false,
rawQuery,
);
});
@@ -3709,7 +3702,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'test query',
);
});
@@ -3859,7 +3851,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'second query',
);
});
@@ -4004,7 +3995,6 @@ describe('useGeminiStream', () => {
expect.any(AbortSignal),
expect.any(String),
undefined,
- false,
'test query',
);
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index eee0241a58..828af9b276 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -227,7 +227,10 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch>,
- onCancelSubmit: (shouldRestorePrompt?: boolean) => void,
+ onCancelSubmit: (
+ shouldRestorePrompt?: boolean,
+ clearBuffer?: boolean,
+ ) => void,
setShellInputFocused: (value: boolean) => void,
terminalWidth: number,
terminalHeight: number,
@@ -803,100 +806,129 @@ export const useGeminiStream = (
[addItem, config, isLowErrorVerbosity],
);
- const cancelOngoingRequest = useCallback(() => {
- if (
- streamingState !== StreamingState.Responding &&
- streamingState !== StreamingState.WaitingForConfirmation
- ) {
- return;
- }
- if (turnCancelledRef.current) {
- return;
- }
- turnCancelledRef.current = true;
- setRetryStatus(null);
-
- // A full cancellation means no tools have produced a final result yet.
- // This determines if we show a generic "Request cancelled" message.
- const isFullCancellation = !toolCalls.some(
- (tc) => tc.status === 'success' || tc.status === 'error',
- );
-
- // Ensure we have an abort controller, creating one if it doesn't exist.
- if (!abortControllerRef.current) {
- abortControllerRef.current = new AbortController();
- }
-
- // The order is important here.
- // 1. Fire the signal to interrupt any active async operations.
- abortControllerRef.current.abort();
- // 2. Call the imperative cancel to clear the queue of pending tools.
- cancelAllToolCalls(abortControllerRef.current.signal);
-
- if (pendingHistoryItemRef.current) {
- const isShellCommand =
- pendingHistoryItemRef.current.type === 'tool_group' &&
- pendingHistoryItemRef.current.tools.some(
- (t) => t.name === SHELL_COMMAND_NAME,
- );
-
- // If it is a shell command, we update the status to Canceled and clear the output
- // to avoid artifacts, then add it to history immediately.
- if (isShellCommand) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const toolGroup = pendingHistoryItemRef.current as HistoryItemToolGroup;
- const updatedTools = toolGroup.tools.map((tool) => {
- if (tool.name === SHELL_COMMAND_NAME) {
- return {
- ...tool,
- status: CoreToolCallStatus.Cancelled,
- resultDisplay: tool.resultDisplay,
- };
- }
- return tool;
- });
- addItem({ ...toolGroup, tools: updatedTools } as HistoryItemWithoutId);
- } else {
- addItem(pendingHistoryItemRef.current);
+ const cancelOngoingRequest = useCallback(
+ (clearBuffer: boolean = true) => {
+ // If we are already cancelled, do nothing
+ if (turnCancelledRef.current) {
+ if (clearBuffer) {
+ onCancelSubmit(false, true);
+ }
+ return;
}
- }
- setPendingHistoryItem(null);
- // If it was a full cancellation, add the info message now.
- // Otherwise, we let handleCompletedTools figure out the next step,
- // which might involve sending partial results back to the model.
- if (isFullCancellation) {
- // If shell is active, we delay this message to ensure correct ordering
- // (Shell item first, then Info message).
- if (!activeShellPtyId) {
- addItem({
- type: MessageType.INFO,
- text: 'Request cancelled.',
- });
- setIsResponding(false);
+ const hasActiveTools = toolCalls.some(
+ (tc) =>
+ tc.status === CoreToolCallStatus.Executing ||
+ tc.status === CoreToolCallStatus.Scheduled ||
+ tc.status === CoreToolCallStatus.Validating,
+ );
+
+ // If we are not responding, not waiting for confirmation, and have no active tools,
+ // there is nothing to abort.
+ if (
+ streamingState === StreamingState.Idle &&
+ !isRespondingRef.current &&
+ !hasActiveTools
+ ) {
+ // Even if we are "idle", if we are called with clearBuffer=true (Ctrl+C),
+ // we still want to clear the buffer.
+ if (clearBuffer) {
+ onCancelSubmit(false, true);
+ }
+ return;
}
- }
- onCancelSubmit(false);
- setShellInputFocused(false);
- }, [
- streamingState,
- addItem,
- setPendingHistoryItem,
- onCancelSubmit,
- pendingHistoryItemRef,
- setShellInputFocused,
- cancelAllToolCalls,
- toolCalls,
- activeShellPtyId,
- setIsResponding,
- ]);
+ turnCancelledRef.current = true;
+ setRetryStatus(null);
+
+ // A full cancellation means no tools have produced a final result yet.
+ // This determines if we show a generic "Request cancelled" message.
+ const isFullCancellation = !toolCalls.some(
+ (tc) => tc.status === 'success' || tc.status === 'error',
+ );
+
+ // Ensure we have an abort controller, creating one if it doesn't exist.
+ if (!abortControllerRef.current) {
+ abortControllerRef.current = new AbortController();
+ }
+
+ // The order is important here.
+ // 1. Fire the signal to interrupt any active async operations.
+ abortControllerRef.current.abort();
+ // 2. Call the imperative cancel to clear the queue of pending tools.
+ cancelAllToolCalls(abortControllerRef.current.signal);
+
+ if (pendingHistoryItemRef.current) {
+ // If it is a shell command, we update the status to Canceled and clear the output
+ // to avoid artifacts, then add it to history immediately.
+ if (
+ pendingHistoryItemRef.current.type === 'tool_group' &&
+ pendingHistoryItemRef.current.tools.some(
+ (t) => t.name === SHELL_COMMAND_NAME,
+ )
+ ) {
+ const toolGroup = pendingHistoryItemRef.current;
+ const updatedTools = toolGroup.tools.map((tool) => {
+ if (tool.name === SHELL_COMMAND_NAME) {
+ return {
+ ...tool,
+ status: CoreToolCallStatus.Cancelled,
+ resultDisplay: tool.resultDisplay,
+ };
+ }
+ return tool;
+ });
+ const newToolGroup: HistoryItemToolGroup = {
+ ...toolGroup,
+ tools: updatedTools,
+ };
+ addItem(newToolGroup);
+ } else {
+ addItem(pendingHistoryItemRef.current);
+ }
+ }
+ setPendingHistoryItem(null);
+
+ // If it was a full cancellation, add the info message now.
+ // Otherwise, we let handleCompletedTools figure out the next step,
+ // which might involve sending partial results back to the model.
+ if (isFullCancellation) {
+ // If shell is active, we delay this message to ensure correct ordering
+ // (Shell item first, then Info message).
+ if (!activeShellPtyId) {
+ addItem({
+ type: MessageType.INFO,
+ text: 'Request cancelled.',
+ });
+ setIsResponding(false);
+ }
+ }
+
+ onCancelSubmit(false, clearBuffer);
+ setShellInputFocused(false);
+ },
+ [
+ streamingState,
+ addItem,
+ setPendingHistoryItem,
+ onCancelSubmit,
+ pendingHistoryItemRef,
+ isRespondingRef,
+ setShellInputFocused,
+ cancelAllToolCalls,
+ toolCalls,
+ activeShellPtyId,
+ setIsResponding,
+ ],
+ );
useKeypress(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
- cancelOngoingRequest();
+ cancelOngoingRequest(false);
+ return true;
}
+ return false;
},
{
isActive:
@@ -1638,7 +1670,6 @@ export const useGeminiStream = (
abortSignal,
prompt_id!,
undefined,
- false,
query,
);
const processingStatus = await processGeminiStreamEvents(
diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.tsx b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx
index 45c861b521..350095a77c 100644
--- a/packages/cli/src/ui/hooks/useGitBranchName.test.tsx
+++ b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx
@@ -12,7 +12,10 @@ import { useGitBranchName } from './useGitBranchName.js';
import { fs, vol } from 'memfs';
import * as fsPromises from 'node:fs/promises';
import path from 'node:path'; // For mocking fs
-import { spawnAsync as mockSpawnAsync } from '@google/gemini-cli-core';
+import {
+ spawnAsync as mockSpawnAsync,
+ getAbsoluteGitDir as mockGetAbsoluteGitDir,
+} from '@google/gemini-cli-core';
// Mock @google/gemini-cli-core
vi.mock('@google/gemini-cli-core', async () => {
@@ -22,6 +25,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...original,
spawnAsync: vi.fn(),
+ getAbsoluteGitDir: vi.fn(),
};
});
@@ -40,19 +44,21 @@ vi.mock('node:fs/promises', async () => {
});
const CWD = '/test/project';
-const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
+const GIT_DIR = path.join(CWD, '.git');
+const GIT_HEAD_PATH = path.join(GIT_DIR, 'HEAD');
describe('useGitBranchName', () => {
let deferredSpawn: Array<{
- resolve: (val: { stdout: string; stderr: string }) => void;
+ resolve: (val: { stdout: string; stderr: string; code: number }) => void;
reject: (err: Error) => void;
args: string[];
}> = [];
beforeEach(() => {
+ vi.useFakeTimers();
vol.reset(); // Reset in-memory filesystem
vol.fromJSON({
- [GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
+ [GIT_HEAD_PATH]: 'ref: refs/heads/main',
});
deferredSpawn = [];
@@ -62,9 +68,11 @@ describe('useGitBranchName', () => {
deferredSpawn.push({ resolve, reject, args });
}),
);
+ vi.mocked(mockGetAbsoluteGitDir).mockResolvedValue(GIT_DIR);
});
afterEach(() => {
+ vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -86,16 +94,35 @@ describe('useGitBranchName', () => {
};
};
+ /**
+ * Helper to resolve pending spawns for a hook render.
+ */
+ const resolveInitialSpawns = async (branch: string = 'main') => {
+ await act(async () => {
+ let resolvedAny = true;
+ while (resolvedAny || deferredSpawn.length > 0) {
+ resolvedAny = false;
+ while (deferredSpawn.length > 0) {
+ const spawn = deferredSpawn.shift()!;
+ if (spawn.args.includes('--abbrev-ref')) {
+ spawn.resolve({ stdout: `${branch}\n`, stderr: '', code: 0 });
+ resolvedAny = true;
+ } else if (spawn.args.includes('--short')) {
+ spawn.resolve({ stdout: `${branch}\n`, stderr: '', code: 0 });
+ resolvedAny = true;
+ }
+ }
+ await vi.advanceTimersByTimeAsync(1);
+ }
+ });
+ };
+
it('should return branch name', async () => {
const { result } = await renderGitBranchNameHook(CWD);
expect(result.current).toBeUndefined();
- await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'main\n', stderr: '' });
- });
+ await resolveInitialSpawns('main');
expect(result.current).toBe('main');
});
@@ -104,9 +131,13 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.reject(new Error('Git error'));
+ const abbrevSpawn = deferredSpawn.find((s) =>
+ s.args.includes('--abbrev-ref'),
+ );
+ if (abbrevSpawn) {
+ abbrevSpawn.reject(new Error('Git error'));
+ }
+ await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBeUndefined();
@@ -116,16 +147,22 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
+ const abbrevSpawn = deferredSpawn.find((s) =>
+ s.args.includes('--abbrev-ref'),
+ )!;
+ abbrevSpawn.resolve({ stdout: 'HEAD\n', stderr: '', code: 0 });
+ await vi.advanceTimersByTimeAsync(1);
});
// It should now call spawnAsync again for the short hash
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--short');
- spawn.resolve({ stdout: 'a1b2c3d\n', stderr: '' });
+ const shortSpawn = deferredSpawn.find((s) => s.args.includes('--short'));
+ if (shortSpawn) {
+ shortSpawn.resolve({ stdout: 'a1b2c3d\n', stderr: '', code: 0 });
+ } else {
+ throw new Error('Short spawn not found');
+ }
+ await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBe('a1b2c3d');
@@ -135,15 +172,21 @@ describe('useGitBranchName', () => {
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'HEAD\n', stderr: '' });
+ const abbrevSpawn = deferredSpawn.find((s) =>
+ s.args.includes('--abbrev-ref'),
+ )!;
+ abbrevSpawn.resolve({ stdout: 'HEAD\n', stderr: '', code: 0 });
+ await vi.advanceTimersByTimeAsync(1);
});
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--short');
- spawn.reject(new Error('Git error'));
+ const shortSpawn = deferredSpawn.find((s) => s.args.includes('--short'));
+ if (shortSpawn) {
+ shortSpawn.reject(new Error('Git error'));
+ } else {
+ throw new Error('Short spawn not found');
+ }
+ await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBeUndefined();
@@ -151,64 +194,94 @@ describe('useGitBranchName', () => {
it('should update branch name when .git/HEAD changes', async () => {
vi.spyOn(fsPromises, 'access').mockResolvedValue(undefined);
- const watchSpy = vi.spyOn(fs, 'watch');
+ let watchCallback:
+ | ((eventType: string, filename: string | null) => void)
+ | undefined;
+ const watchSpy = vi.spyOn(fs, 'watch').mockImplementation(((
+ _path: string,
+ callback: (eventType: string, filename: string | null) => void,
+ ) => {
+ watchCallback = callback;
+ return { close: vi.fn() };
+ }) as unknown as typeof fs.watch);
const { result } = await renderGitBranchNameHook(CWD);
- await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'main\n', stderr: '' });
- });
+ await resolveInitialSpawns('main');
expect(result.current).toBe('main');
// Wait for watcher to be set up
await waitFor(() => {
- expect(watchSpy).toHaveBeenCalled();
+ expect(watchSpy).toHaveBeenCalledWith(GIT_DIR, expect.any(Function));
});
- // Simulate file change event
+ // Simulate file change event for HEAD
await act(async () => {
- fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher
+ if (watchCallback) {
+ watchCallback('change', 'HEAD');
+ }
+ await vi.advanceTimersByTimeAsync(150); // triggers debounce
});
// Resolving the new branch name fetch
await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'develop\n', stderr: '' });
+ // Find the specific abbrev-ref spawn for this update
+ const spawn = deferredSpawn.find((s) => s.args.includes('--abbrev-ref'))!;
+ // Remove it from the array so subsequent lookups don't find the same one
+ deferredSpawn.splice(deferredSpawn.indexOf(spawn), 1);
+ spawn.resolve({ stdout: 'develop\n', stderr: '', code: 0 });
+ await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBe('develop');
+
+ // Simulate file change event with null filename (platform compatibility)
+ await act(async () => {
+ if (watchCallback) {
+ watchCallback('change', null);
+ }
+ await vi.advanceTimersByTimeAsync(150);
+ });
+
+ // Resolving the new branch name fetch
+ await act(async () => {
+ const spawn = deferredSpawn.find((s) => s.args.includes('--abbrev-ref'))!;
+ deferredSpawn.splice(deferredSpawn.indexOf(spawn), 1);
+ spawn.resolve({ stdout: 'feature-x\n', stderr: '', code: 0 });
+ await vi.advanceTimersByTimeAsync(1);
+ });
+
+ expect(result.current).toBe('feature-x');
});
it('should handle watcher setup error silently', async () => {
- // Remove .git/logs/HEAD to cause an error in fs.watch setup
- vol.unlinkSync(GIT_LOGS_HEAD_PATH);
+ // Cause an error in absolute git dir setup
+ vi.mocked(mockGetAbsoluteGitDir).mockRejectedValueOnce(
+ new Error('Git error'),
+ );
const { result } = await renderGitBranchNameHook(CWD);
await act(async () => {
const spawn = deferredSpawn.shift()!;
expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'main\n', stderr: '' });
+ spawn.resolve({ stdout: 'main\n', stderr: '', code: 0 });
+ await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBe('main');
- // This write would trigger the watcher if it was set up
- // We need to create the file again for writeFileSync to not throw
- vol.fromJSON({
- [GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/develop',
- });
-
+ // Trigger a mock write that would normally be watched
await act(async () => {
- fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
+ fs.writeFileSync(GIT_HEAD_PATH, 'ref: refs/heads/develop');
+ await vi.advanceTimersByTimeAsync(1);
});
// spawnAsync should NOT have been called again for updating
- expect(deferredSpawn.length).toBe(0);
+ expect(
+ deferredSpawn.filter((s) => s.args.includes('--abbrev-ref')).length,
+ ).toBe(0);
expect(result.current).toBe('main');
});
@@ -221,18 +294,11 @@ describe('useGitBranchName', () => {
const { unmount } = await renderGitBranchNameHook(CWD);
- await act(async () => {
- const spawn = deferredSpawn.shift()!;
- expect(spawn.args).toContain('--abbrev-ref');
- spawn.resolve({ stdout: 'main\n', stderr: '' });
- });
+ await resolveInitialSpawns('main');
// Wait for watcher to be set up BEFORE unmounting
await waitFor(() => {
- expect(watchMock).toHaveBeenCalledWith(
- GIT_LOGS_HEAD_PATH,
- expect.any(Function),
- );
+ expect(watchMock).toHaveBeenCalledWith(GIT_DIR, expect.any(Function));
});
unmount();
diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts
index 863e3d3c26..fb53635c5e 100644
--- a/packages/cli/src/ui/hooks/useGitBranchName.ts
+++ b/packages/cli/src/ui/hooks/useGitBranchName.ts
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState, useEffect, useCallback } from 'react';
-import { spawnAsync } from '@google/gemini-cli-core';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { spawnAsync, getAbsoluteGitDir } from '@google/gemini-cli-core';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
-import path from 'node:path';
export function useGitBranchName(cwd: string): string | undefined {
const [branchName, setBranchName] = useState(undefined);
+ const timeoutRef = useRef(null);
const fetchBranchName = useCallback(async () => {
try {
@@ -37,26 +37,41 @@ export function useGitBranchName(cwd: string): string | undefined {
}, [cwd, setBranchName]);
useEffect(() => {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- fetchBranchName(); // Initial fetch
+ void fetchBranchName(); // Initial fetch
- const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD');
let watcher: fs.FSWatcher | undefined;
let cancelled = false;
const setupWatcher = async () => {
try {
- // Check if .git/logs/HEAD exists, as it might not in a new repo or orphaned head
- await fsPromises.access(gitLogsHeadPath, fs.constants.F_OK);
+ const gitDir = await getAbsoluteGitDir(cwd);
+ if (!gitDir) return;
+
+ // Ensure we can access the git dir
+ await fsPromises.access(gitDir, fs.constants.F_OK);
if (cancelled) return;
- watcher = fs.watch(gitLogsHeadPath, (eventType: string) => {
- // Changes to .git/logs/HEAD (appends) indicate HEAD has likely changed
- if (eventType === 'change' || eventType === 'rename') {
- // Handle rename just in case
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- fetchBranchName();
- }
- });
+
+ const w = fs.watch(
+ gitDir,
+ (eventType: string, filename: string | null) => {
+ // Changes to HEAD indicate branch checkout or detached commit.
+ // On some platforms filename may be null, so we refresh in that case too.
+ if (!filename || filename === 'HEAD') {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = setTimeout(() => {
+ void fetchBranchName();
+ }, 100);
+ }
+ },
+ );
+
+ if (cancelled) {
+ w.close();
+ } else {
+ watcher = w;
+ }
} catch {
// Silently ignore watcher errors (e.g. permissions or file not existing),
// similar to how exec errors are handled.
@@ -64,11 +79,13 @@ export function useGitBranchName(cwd: string): string | undefined {
}
};
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- setupWatcher();
+ void setupWatcher();
return () => {
cancelled = true;
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
watcher?.close();
};
}, [cwd, fetchBranchName]);
diff --git a/packages/cli/src/ui/hooks/useVoiceMode.ts b/packages/cli/src/ui/hooks/useVoiceMode.ts
index 0f37c66357..e2e61f76d2 100644
--- a/packages/cli/src/ui/hooks/useVoiceMode.ts
+++ b/packages/cli/src/ui/hooks/useVoiceMode.ts
@@ -51,6 +51,7 @@ export function useVoiceMode({
const recorderRef = useRef(null);
const transcriptionServiceRef = useRef(null);
const turnBaselineRef = useRef(null);
+ const turnBaselineCursorOffsetRef = useRef(0);
const pttStateRef = useRef<'idle' | 'possible-hold' | 'recording'>('idle');
const pttTimerRef = useRef(null);
@@ -112,6 +113,7 @@ export function useVoiceMode({
recordingInProgressRef.current = true;
turnBaselineRef.current = bufferRef.current.text;
+ turnBaselineCursorOffsetRef.current = bufferRef.current.getOffset();
setIsConnecting(true);
setIsRecording(true);
@@ -193,29 +195,23 @@ export function useVoiceMode({
}
if (text) {
- const currentBufferText = bufferRef.current.text;
- const previousTranscription = liveTranscriptionRef.current;
+ const baseline = turnBaselineRef.current ?? '';
+ const insertOffset = turnBaselineCursorOffsetRef.current;
+ const textBefore = baseline.slice(0, insertOffset);
+ const textAfter = baseline.slice(insertOffset);
- let newTotalText = currentBufferText;
+ const prefix =
+ textBefore.length > 0 && !/\s$/.test(textBefore)
+ ? textBefore + ' '
+ : textBefore;
- if (
- previousTranscription &&
- currentBufferText.endsWith(previousTranscription)
- ) {
- newTotalText = currentBufferText.slice(
- 0,
- -previousTranscription.length,
- );
- } else if (
- currentBufferText &&
- !currentBufferText.endsWith(' ') &&
- !currentBufferText.endsWith('\n')
- ) {
- newTotalText += ' ';
- }
+ const suffix =
+ text.length > 0 && textAfter.length > 0 && !/^\s/.test(textAfter)
+ ? ' '
+ : '';
- newTotalText += text;
- bufferRef.current.setText(newTotalText, 'end');
+ const newTotalText = prefix + text + suffix + textAfter;
+ bufferRef.current.setText(newTotalText, prefix.length + text.length);
}
liveTranscriptionRef.current = text;
});
@@ -226,6 +222,9 @@ export function useVoiceMode({
stopRequestedRef.current
)
return;
+ // Advance the baseline so subsequent turns append after this turn's text
+ turnBaselineRef.current = bufferRef.current.text;
+ turnBaselineCursorOffsetRef.current = bufferRef.current.getOffset();
liveTranscriptionRef.current = '';
});
diff --git a/packages/cli/src/ui/utils/latexToUnicode.test.ts b/packages/cli/src/ui/utils/latexToUnicode.test.ts
new file mode 100644
index 0000000000..8aab911ce8
--- /dev/null
+++ b/packages/cli/src/ui/utils/latexToUnicode.test.ts
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { convertLatexToUnicode } from './latexToUnicode.js';
+
+describe('convertLatexToUnicode', () => {
+ describe('fast path', () => {
+ it('returns empty string unchanged', () => {
+ expect(convertLatexToUnicode('')).toBe('');
+ });
+
+ it('returns text without backslash or dollar unchanged', () => {
+ const input = 'hello world 123';
+ expect(convertLatexToUnicode(input)).toBe(input);
+ });
+
+ it('short-circuits plain ASCII identically', () => {
+ const input = 'The quick brown fox jumps over the lazy dog.';
+ expect(convertLatexToUnicode(input)).toBe(input);
+ });
+ });
+
+ describe('issue #25656 examples', () => {
+ it('converts the set-of-processes example', () => {
+ const input = 'A set of processes $\\{P_0, P_1, \\dots, P_n\\}$ exists';
+ expect(convertLatexToUnicode(input)).toBe(
+ 'A set of processes {Pā, Pā, ā¦, Pā} exists',
+ );
+ });
+
+ it('converts the deadlock arrow example', () => {
+ const input = 'If the graph contains no cycles $\\to$ No Deadlock.';
+ expect(convertLatexToUnicode(input)).toBe(
+ 'If the graph contains no cycles ā No Deadlock.',
+ );
+ });
+ });
+
+ describe('math delimiters', () => {
+ it('strips $...$ when the content contains LaTeX markers', () => {
+ expect(convertLatexToUnicode('see $\\alpha$ here')).toBe('see α here');
+ });
+
+ it('strips $...$ around single variables', () => {
+ expect(convertLatexToUnicode('let $x$ be a value')).toBe(
+ 'let x be a value',
+ );
+ });
+
+ it('strips $$...$$ display math', () => {
+ expect(convertLatexToUnicode('$$\\alpha + \\beta$$')).toBe('α + β');
+ });
+
+ it('leaves currency $5.99 alone', () => {
+ expect(convertLatexToUnicode('It costs $5.99 total')).toBe(
+ 'It costs $5.99 total',
+ );
+ });
+
+ it('leaves two dollar amounts alone', () => {
+ // The regex matches `$5 to $` as a pair, but the inner content is
+ // neither mathy nor purely variables, so it is left intact.
+ expect(convertLatexToUnicode('prices range $5 to $10')).toBe(
+ 'prices range $5 to $10',
+ );
+ });
+
+ it('leaves shell-style $ interpolation alone', () => {
+ expect(convertLatexToUnicode('echo $USER $HOME')).toBe(
+ 'echo $USER $HOME',
+ );
+ });
+
+ it('does not strip dollars across newlines', () => {
+ expect(convertLatexToUnicode('price $5\nfee $3')).toBe(
+ 'price $5\nfee $3',
+ );
+ });
+ });
+
+ describe('greek letters', () => {
+ it('converts lowercase greek', () => {
+ expect(convertLatexToUnicode('\\alpha \\beta \\gamma')).toBe('α β γ');
+ });
+
+ it('converts uppercase greek', () => {
+ expect(convertLatexToUnicode('\\Omega \\Delta')).toBe('Ī© Ī');
+ });
+
+ it('does not mangle a prefix match', () => {
+ // `\alphabet` is not a known command ā must stay intact.
+ expect(convertLatexToUnicode('\\alphabet')).toBe('\\alphabet');
+ });
+ });
+
+ describe('named commands', () => {
+ it('converts arrows', () => {
+ expect(convertLatexToUnicode('\\to \\rightarrow \\Rightarrow')).toBe(
+ 'ā ā ā',
+ );
+ });
+
+ it('converts relations', () => {
+ expect(convertLatexToUnicode('\\leq \\geq \\neq \\approx')).toBe(
+ '⤠℠ā ā',
+ );
+ });
+
+ it('converts set theory', () => {
+ expect(convertLatexToUnicode('\\in \\notin \\cup \\cap')).toBe('ā ā āŖ ā©');
+ });
+
+ it('converts logic', () => {
+ expect(convertLatexToUnicode('\\forall x \\exists y')).toBe('ā x ā y');
+ });
+
+ it('converts large operators', () => {
+ expect(convertLatexToUnicode('\\sum \\prod \\int')).toBe('ā ā ā«');
+ });
+
+ it('converts ellipses', () => {
+ expect(convertLatexToUnicode('a, b, \\dots, z')).toBe('a, b, ā¦, z');
+ });
+
+ it('converts infty', () => {
+ expect(convertLatexToUnicode('\\infty')).toBe('ā');
+ });
+
+ it('leaves unknown commands untouched', () => {
+ expect(convertLatexToUnicode('\\thisIsNotReal')).toBe('\\thisIsNotReal');
+ });
+ });
+
+ describe('escaped specials', () => {
+ it('unescapes braces and underscore', () => {
+ expect(convertLatexToUnicode('\\{ \\} \\_')).toBe('{ } _');
+ });
+
+ it('unescapes percent, ampersand, hash, dollar, pipe', () => {
+ expect(convertLatexToUnicode('\\% \\& \\# \\$ \\|')).toBe('% & # $ |');
+ });
+
+ it('unescapes backslash-space as a regular space', () => {
+ expect(convertLatexToUnicode('word\\ boundary')).toBe('word boundary');
+ });
+
+ it('converts \\\\ to a newline inside math mode', () => {
+ // `\\` is a LaTeX line break in math/tabular contexts. Only convert
+ // inside `$...$` ā outside math this would mangle Windows UNC paths
+ // (`\\server\share`) and escaped backslashes in code-like prose.
+ expect(convertLatexToUnicode('$a\\\\b$')).toBe('a\nb');
+ });
+
+ it('leaves \\\\ alone outside math mode', () => {
+ expect(convertLatexToUnicode('line1\\\\line2')).toBe('line1\\\\line2');
+ });
+ });
+
+ describe('text formatting', () => {
+ it('wraps textbf in markdown bold', () => {
+ expect(convertLatexToUnicode('\\textbf{hello}')).toBe('**hello**');
+ });
+
+ it('wraps textit in markdown italic', () => {
+ expect(convertLatexToUnicode('\\textit{hello}')).toBe('*hello*');
+ });
+
+ it('strips \\text wrapper', () => {
+ expect(convertLatexToUnicode('\\text{plain}')).toBe('plain');
+ });
+
+ it('strips \\mathrm', () => {
+ expect(convertLatexToUnicode('\\mathrm{foo}')).toBe('foo');
+ });
+
+ it('handles \\emph as italic', () => {
+ expect(convertLatexToUnicode('\\emph{emphasized}')).toBe('*emphasized*');
+ });
+ });
+
+ describe('fractions and roots', () => {
+ it('converts \\frac', () => {
+ expect(convertLatexToUnicode('\\frac{a}{b}')).toBe('(a)/(b)');
+ });
+
+ it('converts \\sqrt', () => {
+ expect(convertLatexToUnicode('\\sqrt{x}')).toBe('ā(x)');
+ });
+
+ it('converts \\sqrt with index', () => {
+ expect(convertLatexToUnicode('\\sqrt[3]{x}')).toBe('3ā(x)');
+ });
+
+ it('converts \\frac combined with greek', () => {
+ expect(convertLatexToUnicode('\\frac{\\alpha}{\\beta}')).toBe('(α)/(β)');
+ });
+ });
+
+ describe('subscripts and superscripts', () => {
+ // Sub/superscripts are only applied inside math delimiters to avoid
+ // mangling identifiers like `file_name` and `foo_bar` in regular prose.
+ it('converts digit subscripts inside math', () => {
+ expect(convertLatexToUnicode('$x_0 + x_1 + x_2$')).toBe('xā + xā + xā');
+ });
+
+ it('converts digit superscripts inside math', () => {
+ expect(convertLatexToUnicode('$E = mc^2$')).toBe('E = mc²');
+ });
+
+ it('converts letter subscripts where available', () => {
+ expect(convertLatexToUnicode('$P_n$ and $x_i$')).toBe('Pā and xįµ¢');
+ });
+
+ it('converts braced digit subscripts', () => {
+ expect(convertLatexToUnicode('$x_{12}$')).toBe('xāā');
+ });
+
+ it('leaves subscripts with no unicode mapping alone', () => {
+ // `q` has no subscript glyph in Unicode ā leave the whole operand
+ // untouched to avoid inconsistent-looking output.
+ expect(convertLatexToUnicode('$x_{abq}$')).toBe('x_{abq}');
+ });
+
+ it('does not subscript identifiers in prose', () => {
+ // Outside math delimiters, `_` is left alone entirely so that
+ // snake_case identifiers and file paths render correctly. This is a
+ // deliberate trade-off against model output that emits subscripts
+ // unwrapped.
+ expect(convertLatexToUnicode('the file_name variable')).toBe(
+ 'the file_name variable',
+ );
+ expect(convertLatexToUnicode('_private')).toBe('_private');
+ });
+
+ it('does not superscript when character is unmapped in sup', () => {
+ // `^Q` ā Q has no superscript. The regex only matches when the char is
+ // in the map; leave as-is even inside math.
+ expect(convertLatexToUnicode('$x^Q$')).toBe('x^Q');
+ });
+
+ it('leaves bare x_0 alone outside math', () => {
+ // Deliberate: we cannot tell `P_0` (subscript) from `my_0` (identifier)
+ // in arbitrary prose, so prefer to preserve identifiers.
+ expect(convertLatexToUnicode('x_0 is fine')).toBe('x_0 is fine');
+ });
+ });
+
+ describe('protection of non-LaTeX content', () => {
+ it('leaves Windows paths alone', () => {
+ expect(convertLatexToUnicode('C:\\Users\\foo\\bar')).toBe(
+ 'C:\\Users\\foo\\bar',
+ );
+ });
+
+ it('leaves Windows UNC paths alone (no line-break rewrite in prose)', () => {
+ // `\\server\share\file` must NOT be rewritten to a newline. Line-break
+ // conversion is restricted to math mode. See PR #25802.
+ expect(convertLatexToUnicode('\\\\server\\share\\file')).toBe(
+ '\\\\server\\share\\file',
+ );
+ });
+
+ it('leaves regex backslash escapes alone', () => {
+ expect(convertLatexToUnicode('\\d+\\w*')).toBe('\\d+\\w*');
+ });
+
+ it('leaves $ in code-like prose alone', () => {
+ expect(convertLatexToUnicode('run $(command)$ to see output')).toBe(
+ 'run $(command)$ to see output',
+ );
+ });
+ });
+
+ describe('combined scenarios', () => {
+ it('handles complex math in prose', () => {
+ const input =
+ 'The complexity is $O(n \\log n)$ for sorting $n$ elements.';
+ expect(convertLatexToUnicode(input)).toBe(
+ 'The complexity is O(n log n) for sorting n elements.',
+ );
+ });
+
+ it('handles multiple constructs in one line', () => {
+ const input = 'Let $\\alpha \\in \\mathbb{R}$ and $\\beta \\geq 0$.';
+ expect(convertLatexToUnicode(input)).toBe('Let α ā R and β ā„ 0.');
+ });
+
+ it('preserves surrounding text exactly', () => {
+ const input = 'Before $\\to$ after.';
+ expect(convertLatexToUnicode(input)).toBe('Before ā after.');
+ });
+
+ it('idempotency ā running twice yields the same result', () => {
+ const input = '$\\{P_0, \\dots, P_n\\}$';
+ const once = convertLatexToUnicode(input);
+ const twice = convertLatexToUnicode(once);
+ expect(twice).toBe(once);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/latexToUnicode.ts b/packages/cli/src/ui/utils/latexToUnicode.ts
new file mode 100644
index 0000000000..f021d70f0d
--- /dev/null
+++ b/packages/cli/src/ui/utils/latexToUnicode.ts
@@ -0,0 +1,599 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Converts common LaTeX-style syntax in model output into terminal-friendly
+ * Unicode (and lightweight markdown where appropriate).
+ *
+ * Terminals cannot natively render LaTeX, but model responses ā especially for
+ * math, CS, and algorithms ā frequently include constructs like `$\{P_0,
+ * \dots, P_n\}$` or `$\to$`. Left as-is, the raw backslash commands show up
+ * verbatim and make the output look broken.
+ *
+ * This function is a conservative, lossy post-processor that handles the
+ * common cases and leaves anything it does not recognise untouched, so that
+ * legitimate backslash content (e.g. Windows paths, regex examples) is not
+ * mangled.
+ *
+ * See issue #25656.
+ */
+
+// Greek letters, lower and upper case, plus the common "var" variants.
+const GREEK_LETTERS: Readonly> = Object.freeze({
+ alpha: 'α',
+ beta: 'β',
+ gamma: 'γ',
+ delta: 'Ī“',
+ epsilon: 'ε',
+ zeta: 'ζ',
+ eta: 'Ī·',
+ theta: 'Īø',
+ iota: 'ι',
+ kappa: 'Īŗ',
+ lambda: 'Ī»',
+ mu: 'μ',
+ nu: 'ν',
+ xi: 'ξ',
+ omicron: 'Īæ',
+ pi: 'Ļ',
+ rho: 'Ļ',
+ sigma: 'Ļ',
+ tau: 'Ļ',
+ upsilon: 'Ļ
',
+ phi: 'Ļ',
+ chi: 'Ļ',
+ psi: 'Ļ',
+ omega: 'Ļ',
+ Alpha: 'Ī',
+ Beta: 'Ī',
+ Gamma: 'Ī',
+ Delta: 'Ī',
+ Epsilon: 'Ī',
+ Zeta: 'Ī',
+ Eta: 'Ī',
+ Theta: 'Ī',
+ Iota: 'Ī',
+ Kappa: 'Ī',
+ Lambda: 'Ī',
+ Mu: 'Ī',
+ Nu: 'Ī',
+ Xi: 'Ī',
+ Omicron: 'Ī',
+ Pi: 'Ī ',
+ Rho: 'Ī”',
+ Sigma: 'Ī£',
+ Tau: 'Τ',
+ Upsilon: 'Ī„',
+ Phi: 'Φ',
+ Chi: 'Χ',
+ Psi: 'ĪØ',
+ Omega: 'Ī©',
+ varepsilon: 'ε',
+ vartheta: 'Ļ',
+ varphi: 'Ļ',
+ varrho: 'ϱ',
+ varsigma: 'Ļ',
+ varpi: 'Ļ',
+});
+
+// Named LaTeX commands ā Unicode. Covers arrows, relations, set theory,
+// logic, large operators, and a handful of common decorations. Anything not
+// listed here is deliberately left untouched.
+const LATEX_COMMANDS: Readonly> = Object.freeze({
+ // Arrows
+ to: 'ā',
+ rightarrow: 'ā',
+ Rightarrow: 'ā',
+ leftarrow: 'ā',
+ Leftarrow: 'ā',
+ leftrightarrow: 'ā',
+ Leftrightarrow: 'ā',
+ mapsto: 'ā¦',
+ longrightarrow: 'ā¶',
+ longleftarrow: 'āµ',
+ longleftrightarrow: 'ā·',
+ uparrow: 'ā',
+ downarrow: 'ā',
+ Uparrow: 'ā',
+ Downarrow: 'ā',
+ hookrightarrow: 'āŖ',
+ hookleftarrow: 'ā©',
+
+ // Ellipses
+ dots: 'ā¦',
+ ldots: 'ā¦',
+ cdots: 'āÆ',
+ vdots: 'ā®',
+ ddots: 'ā±',
+
+ // Arithmetic / comparison
+ times: 'Ć',
+ cdot: 'Ā·',
+ div: 'Ć·',
+ pm: '±',
+ mp: 'ā',
+ ast: 'ā',
+ leq: 'ā¤',
+ le: 'ā¤',
+ geq: 'ā„',
+ ge: 'ā„',
+ neq: 'ā ',
+ ne: 'ā ',
+ ll: 'āŖ',
+ gg: 'ā«',
+ approx: 'ā',
+ equiv: 'ā”',
+ sim: 'ā¼',
+ simeq: 'ā',
+ cong: 'ā
',
+ propto: 'ā',
+
+ // Set theory
+ in: 'ā',
+ notin: 'ā',
+ ni: 'ā',
+ subset: 'ā',
+ supset: 'ā',
+ subseteq: 'ā',
+ supseteq: 'ā',
+ cup: 'āŖ',
+ cap: 'ā©',
+ setminus: 'ā',
+ emptyset: 'ā
',
+ varnothing: 'ā
',
+
+ // Logic
+ forall: 'ā',
+ exists: 'ā',
+ nexists: 'ā',
+ neg: '¬',
+ lnot: '¬',
+ land: 'ā§',
+ wedge: 'ā§',
+ lor: 'āØ',
+ vee: 'āØ',
+ oplus: 'ā',
+ otimes: 'ā',
+ implies: 'ā¹',
+ iff: 'āŗ',
+
+ // Large operators
+ sum: 'ā',
+ prod: 'ā',
+ coprod: 'ā',
+ int: 'ā«',
+ iint: 'ā¬',
+ iiint: 'ā',
+ oint: 'ā®',
+
+ // Calculus
+ partial: 'ā',
+ nabla: 'ā',
+ infty: 'ā',
+
+ // Misc letters / constants
+ ell: 'ā',
+ hbar: 'ā',
+ Re: 'ā',
+ Im: 'ā',
+ aleph: 'āµ',
+ beth: 'ā¶',
+
+ // Brackets / delimiters
+ lbrace: '{',
+ rbrace: '}',
+ lbrack: '[',
+ rbrack: ']',
+ langle: 'āØ',
+ rangle: 'ā©',
+ lceil: 'ā',
+ rceil: 'ā',
+ lfloor: 'ā',
+ rfloor: 'ā',
+
+ // Geometry / misc
+ perp: 'ā„',
+ parallel: 'ā„',
+ angle: 'ā ',
+ triangle: 'ā³',
+ square: 'ā”',
+ circ: 'ā',
+ bullet: 'ā¢',
+ star: 'ā',
+ prime: 'ā²',
+ dag: 'ā ',
+ ddag: 'ā”',
+ therefore: 'ā“',
+ because: 'āµ',
+ top: 'ā¤',
+ bot: 'ā„',
+
+ // Operator names (`\log`, `\sin`, ā¦) render in LaTeX as upright text. In a
+ // terminal the closest equivalent is the lowercase word itself.
+ log: 'log',
+ ln: 'ln',
+ lg: 'lg',
+ exp: 'exp',
+ sin: 'sin',
+ cos: 'cos',
+ tan: 'tan',
+ cot: 'cot',
+ sec: 'sec',
+ csc: 'csc',
+ arcsin: 'arcsin',
+ arccos: 'arccos',
+ arctan: 'arctan',
+ sinh: 'sinh',
+ cosh: 'cosh',
+ tanh: 'tanh',
+ max: 'max',
+ min: 'min',
+ sup: 'sup',
+ inf: 'inf',
+ lim: 'lim',
+ limsup: 'lim sup',
+ liminf: 'lim inf',
+ arg: 'arg',
+ det: 'det',
+ dim: 'dim',
+ ker: 'ker',
+ gcd: 'gcd',
+ deg: 'deg',
+ hom: 'hom',
+ mod: 'mod',
+ bmod: 'mod',
+ pmod: 'mod',
+
+ // Whitespace commands ā render as visible space so layout is roughly right.
+ quad: ' ',
+ qquad: ' ',
+ // These are all "thin-space" style commands in LaTeX; render as a single
+ // space so the surrounding tokens don't jam together.
+ ',': ' ',
+ ';': ' ',
+ ':': ' ',
+ '!': '',
+});
+
+// Unicode subscript mappings (digits, operators, and the common letters that
+// have full-height subscript glyphs in Unicode).
+const SUBSCRIPT_MAP: Readonly> = Object.freeze({
+ '0': 'ā',
+ '1': 'ā',
+ '2': 'ā',
+ '3': 'ā',
+ '4': 'ā',
+ '5': 'ā
',
+ '6': 'ā',
+ '7': 'ā',
+ '8': 'ā',
+ '9': 'ā',
+ '+': 'ā',
+ '-': 'ā',
+ '=': 'ā',
+ '(': 'ā',
+ ')': 'ā',
+ a: 'ā',
+ e: 'ā',
+ h: 'ā',
+ i: 'įµ¢',
+ j: 'ā±¼',
+ k: 'ā',
+ l: 'ā',
+ m: 'ā',
+ n: 'ā',
+ o: 'ā',
+ p: 'ā',
+ r: 'įµ£',
+ s: 'ā',
+ t: 'ā',
+ u: 'ᵤ',
+ v: 'ᵄ',
+ x: 'ā',
+});
+
+// Unicode superscript mappings. A superset of subscripts ā most letters have
+// superscript glyphs.
+const SUPERSCRIPT_MAP: Readonly> = Object.freeze({
+ '0': 'ā°',
+ '1': '¹',
+ '2': '²',
+ '3': '³',
+ '4': 'ā“',
+ '5': 'āµ',
+ '6': 'ā¶',
+ '7': 'ā·',
+ '8': 'āø',
+ '9': 'ā¹',
+ '+': 'āŗ',
+ '-': 'ā»',
+ '=': 'ā¼',
+ '(': 'ā½',
+ ')': 'ā¾',
+ a: 'įµ',
+ b: 'įµ',
+ c: 'į¶',
+ d: 'įµ',
+ e: 'įµ',
+ f: 'į¶ ',
+ g: 'įµ',
+ h: 'ʰ',
+ i: 'ā±',
+ j: 'ʲ',
+ k: 'įµ',
+ l: 'Ė”',
+ m: 'įµ',
+ n: 'āæ',
+ o: 'įµ',
+ p: 'įµ',
+ r: 'ʳ',
+ s: 'Ė¢',
+ t: 'įµ',
+ u: 'įµ',
+ v: 'įµ',
+ w: 'Ź·',
+ x: 'Ė£',
+ y: 'Źø',
+ z: 'į¶»',
+});
+
+/**
+ * Strips `$...$` and `$$...$$` math delimiters when the inner content looks
+ * like math, applying the full set of math-mode conversions (including
+ * sub/superscripts) to the inner text. The goal is to handle model output
+ * without eating dollar signs that appear in ordinary prose (prices,
+ * shell examples, etc.).
+ *
+ * A pair of `$...$` is treated as math when the inner text either:
+ * - contains a LaTeX marker (`\command`, `_`, `^`), or
+ * - is a single letter, possibly with whitespace padding (e.g. `$x$`,
+ * `$ n $`). Shell-style variables like `$USER` are LEFT intact because
+ * multi-letter all-caps sequences look much more like shell vars than
+ * math in practice.
+ *
+ * A currency expression like `$5.99` (single `$`) never matches the pair
+ * regex. `From $5 to $10` matches `$5 to $` as a pair but the inner text is
+ * neither mathy nor a single variable, so it is left intact.
+ */
+function stripMathDelimiters(text: string): string {
+ // Display math first, greedy-safe with non-dollar inner class.
+ let out = text.replace(/\$\$([^$]+)\$\$/g, (_, inner: string) =>
+ applyMathModeConversions(inner),
+ );
+
+ // Inline math: lazy, single-line to avoid eating across paragraphs.
+ out = out.replace(/\$([^$\n]+?)\$/g, (match, inner: string) => {
+ const hasLatexMarkers = /\\[A-Za-z]|[\\_^]/.test(inner);
+ const isSingleVariable = /^\s*[A-Za-z]\s*$/.test(inner);
+ if (hasLatexMarkers || isSingleVariable) {
+ return applyMathModeConversions(inner);
+ }
+ return match;
+ });
+
+ return out;
+}
+
+/**
+ * Converts `\textbf{..}`, `\textit{..}`, `\emph{..}`, `\text{..}`,
+ * `\mathrm{..}`, `\mathbf{..}`, `\mathit{..}`, `\mathsf{..}`, `\mathtt{..}`,
+ * and `\operatorname{..}` into markdown-equivalent wrappers or plain text so
+ * the regular inline parser picks them up downstream.
+ *
+ * Only handles a single level of nesting (no inner braces) ā this keeps the
+ * regex bounded and avoids catastrophic backtracking on adversarial input.
+ */
+function convertTextFormatting(text: string): string {
+ let out = text;
+ out = out.replace(
+ /\\(?:textbf|mathbf)\{([^{}]*)\}/g,
+ (_, inner: string) => `**${inner}**`,
+ );
+ out = out.replace(
+ /\\(?:textit|emph|mathit)\{([^{}]*)\}/g,
+ (_, inner: string) => `*${inner}*`,
+ );
+ out = out.replace(
+ /\\(?:text|mathrm|mathsf|mathtt|mathbb|mathcal|mathfrak|operatorname)\{([^{}]*)\}/g,
+ (_, inner: string) => inner,
+ );
+ return out;
+}
+
+/**
+ * Handles `\frac{a}{b}` ā `(a)/(b)` and `\sqrt{x}` ā `ā(x)`.
+ * Only a single level of braces is supported.
+ */
+function convertFractionsAndRoots(text: string): string {
+ let out = text;
+ out = out.replace(
+ /\\frac\{([^{}]*)\}\{([^{}]*)\}/g,
+ (_, num: string, den: string) => `(${num})/(${den})`,
+ );
+ out = out.replace(
+ /\\sqrt\[([^\]]*)\]\{([^{}]*)\}/g,
+ (_, index: string, radicand: string) => `${index}ā(${radicand})`,
+ );
+ out = out.replace(
+ /\\sqrt\{([^{}]*)\}/g,
+ (_, radicand: string) => `ā(${radicand})`,
+ );
+ return out;
+}
+
+/**
+ * Converts escaped single-character specials (`\{` ā `{`, `\_` ā `_`, etc.).
+ * Runs before command lookup so `\{` is not misread as a command named `{`.
+ */
+function convertEscapedSpecials(text: string): string {
+ // The set is intentionally narrow: only characters that have meaning in
+ // LaTeX and also appear unescaped in plain text. We do not unescape `\\`
+ // (line break) here ā it is handled separately.
+ let out = text.replace(/\\([{}[\]_%$|])/g, (_, ch: string) => ch);
+ // `\ ` (backslash + space) is LaTeX for a non-breaking space; just keep it
+ // as a regular space so words do not collide.
+ out = out.replace(/\\ /g, ' ');
+ return out;
+}
+
+/**
+ * Converts named commands (alphabetic control sequences) to Unicode. Anything
+ * not in the tables is left as-is so unrelated backslash content
+ * (e.g. Windows paths) is not disturbed.
+ */
+function convertNamedCommands(text: string): string {
+ return text.replace(
+ /\\([A-Za-z]+)(?![A-Za-z])/g,
+ (match, name: string) =>
+ GREEK_LETTERS[name] ?? LATEX_COMMANDS[name] ?? match,
+ );
+}
+
+/**
+ * Converts the short-form punctuation commands `\,`, `\;`, `\:`, `\!` used
+ * for spacing in LaTeX. These are handled separately from alphabetic commands
+ * because the regex for the latter only matches letters.
+ */
+function convertPunctuationCommands(text: string): string {
+ // `\,`, `\;`, `\:` all render as a single space; `\!` is a negative space
+ // and is stripped.
+ return text.replace(/\\([,;:!])/g, (_, ch: string) => {
+ switch (ch) {
+ case ',':
+ case ';':
+ case ':':
+ return ' ';
+ case '!':
+ return '';
+ default:
+ return ch;
+ }
+ });
+}
+
+/**
+ * Converts the `\\` line-break command (used inside math environments and
+ * tables) to a literal newline. Must run after `\` specials but before any
+ * other regex that might see a lingering backslash.
+ */
+function convertLineBreaks(text: string): string {
+ return text.replace(/\\\\/g, '\n');
+}
+
+/**
+ * Converts subscripts and superscripts to Unicode where every character in
+ * the operand maps. If any character has no mapping the whole operand is
+ * left alone, to avoid "half-converted" output that looks worse than no
+ * conversion.
+ */
+function convertSubSuperScripts(text: string): string {
+ // Braced form first: x_{...}, x^{...}. We only support BMP characters (the
+ // mapping tables are ASCII-only), so iterating with `Array.from` over code
+ // units is safe and keeps the lint rule against splitting strings happy.
+ const charsOf = (s: string): string[] => Array.from(s);
+
+ let out = text.replace(/_\{([^{}]+)\}/g, (match, inner: string) => {
+ const chars = charsOf(inner);
+ if (chars.every((c) => SUBSCRIPT_MAP[c] !== undefined)) {
+ return chars.map((c) => SUBSCRIPT_MAP[c]).join('');
+ }
+ return match;
+ });
+ out = out.replace(/\^\{([^{}]+)\}/g, (match, inner: string) => {
+ const chars = charsOf(inner);
+ if (chars.every((c) => SUPERSCRIPT_MAP[c] !== undefined)) {
+ return chars.map((c) => SUPERSCRIPT_MAP[c]).join('');
+ }
+ return match;
+ });
+
+ // Single-character form: x_0, x^2. Only convert when the character actually
+ // has a mapping ā leaves `file_name` and `foo^bar` alone.
+ out = out.replace(
+ /([A-Za-z0-9)\]])_([A-Za-z0-9+\-=()])/g,
+ (match, base: string, c: string) => {
+ const sub = SUBSCRIPT_MAP[c];
+ return sub ? `${base}${sub}` : match;
+ },
+ );
+ out = out.replace(
+ /([A-Za-z0-9)\]])\^([A-Za-z0-9+\-=()])/g,
+ (match, base: string, c: string) => {
+ const sup = SUPERSCRIPT_MAP[c];
+ return sup ? `${base}${sup}` : match;
+ },
+ );
+
+ return out;
+}
+
+/**
+ * Applies the full set of conversions that make sense inside a LaTeX math
+ * region (i.e. text that was originally wrapped in `$...$`). This includes
+ * sub/superscripts, which are NOT safe to apply to arbitrary prose because
+ * they would mangle identifiers like `file_name`.
+ */
+function applyMathModeConversions(text: string): string {
+ let out = text;
+ out = convertTextFormatting(out);
+ out = convertFractionsAndRoots(out);
+ out = convertEscapedSpecials(out);
+ out = convertLineBreaks(out);
+ out = convertNamedCommands(out);
+ out = convertPunctuationCommands(out);
+ out = convertSubSuperScripts(out);
+ return out;
+}
+
+/**
+ * Applies conversions that are safe to run on arbitrary prose ā anything
+ * keyed off explicit LaTeX tokens like `\alpha`, `\textbf{...}`, `\to`. Does
+ * NOT touch standalone `_` or `^` so identifiers and snake_case names are
+ * preserved.
+ */
+function applyProseConversions(text: string): string {
+ let out = text;
+ out = convertTextFormatting(out);
+ out = convertFractionsAndRoots(out);
+ out = convertEscapedSpecials(out);
+ // Deliberately NOT running convertLineBreaks here: outside math delimiters
+ // `\\` is far more likely to be a Windows UNC path (`\\server\share`) or an
+ // escaped backslash in code-like prose than a LaTeX line break. Legitimate
+ // LaTeX line breaks belong inside `$...$` or `$$...$$` and are handled by
+ // applyMathModeConversions. See PR #25802 review.
+ out = convertNamedCommands(out);
+ out = convertPunctuationCommands(out);
+ return out;
+}
+
+/**
+ * Top-level entry point. Two-phase conversion:
+ *
+ * 1. Strip `$...$` / `$$...$$` math regions, applying math-mode conversions
+ * (including sub/superscripts) to the inner text. The heuristic for
+ * "this dollar pair is math" runs against the ORIGINAL input so that
+ * model-authored LaTeX is recognised before any tokens are rewritten.
+ *
+ * 2. Run prose-safe conversions over the remaining text, catching
+ * unwrapped LaTeX tokens (`\alpha`, `\to`, `\textbf{...}`) that the
+ * model emitted outside math delimiters.
+ *
+ * Short-circuits on input that has no LaTeX markers at all (`\` or `$`) so
+ * the hot rendering path stays cheap for ordinary prose.
+ */
+export function convertLatexToUnicode(input: string): string {
+ if (!input) return input;
+ // Fast path: if there's no backslash and no dollar sign, there's nothing to
+ // convert. This keeps the hot rendering path inexpensive for ordinary text.
+ if (input.indexOf('\\') === -1 && input.indexOf('$') === -1) {
+ return input;
+ }
+
+ let text = input;
+ text = stripMathDelimiters(text);
+ text = applyProseConversions(text);
+ return text;
+}
diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
index c32bda58fa..5728f886dc 100644
--- a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
+++ b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
@@ -222,5 +222,52 @@ describe('parsingUtils', () => {
),
);
});
+
+ describe('LaTeX conversion (issue #25656)', () => {
+ it('converts LaTeX in plain text (no markdown tokens)', () => {
+ const input = 'No cycles $\\to$ no deadlock';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(primary('No cycles ā no deadlock'));
+ });
+
+ it('converts LaTeX in the set example from the issue', () => {
+ const input = 'Processes $\\{P_0, \\dots, P_n\\}$';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(primary('Processes {Pā, ā¦, Pā}'));
+ });
+
+ it('preserves LaTeX inside inline code', () => {
+ // Content between backticks must be rendered verbatim ā conversion
+ // must NOT be applied inside code spans, even when the code contains
+ // `$...$` that would otherwise be stripped.
+ const input = 'use `$\\to$` for an arrow';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(
+ `${primary('use ')}${accent('$\\to$')}${primary(' for an arrow')}`,
+ );
+ });
+
+ it('converts LaTeX in slices around markdown tokens', () => {
+ const input = '$\\alpha$ is **bold** and $\\beta$ is plain';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(
+ `${primary('α is ')}${chalk.bold(primary('bold'))}${primary(
+ ' and β is plain',
+ )}`,
+ );
+ });
+
+ it('leaves Windows paths alone', () => {
+ const input = 'Path: C:\\Users\\foo';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(primary('Path: C:\\Users\\foo'));
+ });
+
+ it('leaves currency amounts alone', () => {
+ const input = 'It costs $5.99 total';
+ const output = parseMarkdownToANSI(input);
+ expect(output).toBe(primary('It costs $5.99 total'));
+ });
+ });
});
});
diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.ts b/packages/cli/src/ui/utils/markdownParsingUtils.ts
index 10f7cb7a40..841809f08c 100644
--- a/packages/cli/src/ui/utils/markdownParsingUtils.ts
+++ b/packages/cli/src/ui/utils/markdownParsingUtils.ts
@@ -12,6 +12,7 @@ import {
} from '../themes/color-utils.js';
import { theme } from '../semantic-colors.js';
import { debugLogger } from '@google/gemini-cli-core';
+import { convertLatexToUnicode } from './latexToUnicode.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
@@ -72,11 +73,49 @@ const ansiColorize = (str: string, color: string | undefined): string => {
* Converts markdown text into a string with ANSI escape codes.
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
*/
+// Private-Use-Area codepoint used as a placeholder sentinel when masking
+// inline code / URL spans from LaTeX conversion. Not touched by
+// stripUnsafeCharacters and not matched by the markdown tokenizer.
+const MASK_SENTINEL = '\uE000';
+const MASK_PATTERN = /\uE000(\d+)\uE000/g;
+
+/**
+ * Runs LaTeX conversion on `text` while keeping inline code spans and bare
+ * URLs verbatim. Without masking, the LaTeX pass would happily rewrite
+ * ``$\to$`` inside a backtick code span ā violating the "code is verbatim"
+ * contract ā and could rewrite URL query strings containing `$`.
+ */
+const convertLatexPreservingSpans = (text: string): string => {
+ const preserved: string[] = [];
+ // Match inline code spans (with matched backtick counts) and bare URLs.
+ // Order matters: code spans first so they win over a URL inside a span.
+ const masked = text.replace(/(`+)([^`\n]+?)\1|https?:\/\/\S+/g, (match) => {
+ const index = preserved.push(match) - 1;
+ return `${MASK_SENTINEL}${index}${MASK_SENTINEL}`;
+ });
+ const converted = convertLatexToUnicode(masked);
+ return converted.replace(
+ MASK_PATTERN,
+ // Fallback to the literal match if the index is somehow out of range ā
+ // defensive against the unlikely case where the PUA sentinel appears in
+ // user input. Without the fallback, replace would emit "undefined".
+ (match, i: string) => preserved[Number(i)] ?? match,
+ );
+};
+
export const parseMarkdownToANSI = (
- text: string,
+ rawText: string,
defaultColor?: string,
): string => {
const baseColor = defaultColor ?? theme.text.primary;
+ // Convert LaTeX-style math/commands to Unicode BEFORE tokenizing markdown,
+ // so constructs like `$\{P_0, \dots, P_n\}$` are handled as a whole even
+ // when they contain underscores (which the tokenizer would otherwise treat
+ // as italic markers). Inline code and URLs are masked during the
+ // conversion so their contents are preserved verbatim. Unknown `\foo`
+ // sequences are left alone, so Windows paths and regex escapes survive.
+ // See issue #25656.
+ const text = convertLatexPreservingSpans(rawText);
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return ansiColorize(text, baseColor);
diff --git a/packages/cli/src/ui/utils/memorySnapshot.test.ts b/packages/cli/src/ui/utils/memorySnapshot.test.ts
new file mode 100644
index 0000000000..91fac95197
--- /dev/null
+++ b/packages/cli/src/ui/utils/memorySnapshot.test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { Readable } from 'node:stream';
+import {
+ captureHeapSnapshot,
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
+} from './memorySnapshot.js';
+
+const { mkdirMock, pipelineMock, getHeapSnapshotMock, createWriteStreamMock } =
+ vi.hoisted(() => ({
+ mkdirMock: vi.fn(async () => undefined),
+ pipelineMock: vi.fn(async () => undefined),
+ getHeapSnapshotMock: vi.fn(),
+ createWriteStreamMock: vi.fn(),
+ }));
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, mkdir: mkdirMock };
+});
+
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, createWriteStream: createWriteStreamMock };
+});
+
+vi.mock('node:v8', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, getHeapSnapshot: getHeapSnapshotMock };
+});
+
+vi.mock('node:stream/promises', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, pipeline: pipelineMock };
+});
+
+describe('captureHeapSnapshot', () => {
+ beforeEach(() => {
+ mkdirMock.mockClear();
+ pipelineMock.mockClear();
+ getHeapSnapshotMock.mockClear().mockReturnValue(Readable.from([]));
+ createWriteStreamMock
+ .mockClear()
+ .mockReturnValue({ write: vi.fn(), end: vi.fn() });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('exports the 2 GB auto-capture threshold', () => {
+ expect(MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
+ });
+
+ it('creates the target directory and pipelines the V8 snapshot to disk', async () => {
+ const target = '/tmp/gemini-test/snapshot.heapsnapshot';
+
+ await captureHeapSnapshot(target);
+
+ expect(mkdirMock).toHaveBeenCalledWith('/tmp/gemini-test', {
+ recursive: true,
+ });
+ expect(getHeapSnapshotMock).toHaveBeenCalledTimes(1);
+ expect(createWriteStreamMock).toHaveBeenCalledWith(target);
+ expect(pipelineMock).toHaveBeenCalledTimes(1);
+ expect(pipelineMock).toHaveBeenCalledWith(
+ getHeapSnapshotMock.mock.results[0].value,
+ createWriteStreamMock.mock.results[0].value,
+ );
+ });
+
+ it('propagates pipeline failures to the caller', async () => {
+ pipelineMock.mockRejectedValueOnce(new Error('write failed'));
+
+ await expect(
+ captureHeapSnapshot('/tmp/gemini-test/fail.heapsnapshot'),
+ ).rejects.toThrow('write failed');
+ });
+});
diff --git a/packages/cli/src/ui/utils/memorySnapshot.ts b/packages/cli/src/ui/utils/memorySnapshot.ts
new file mode 100644
index 0000000000..746f3a5d0f
--- /dev/null
+++ b/packages/cli/src/ui/utils/memorySnapshot.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createWriteStream } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import { dirname } from 'node:path';
+import { pipeline } from 'node:stream/promises';
+import { getHeapSnapshot } from 'node:v8';
+
+/**
+ * RSS threshold at which `/bug` auto-captures a heap snapshot.
+ */
+export const MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
+
+/**
+ * Capture a V8 heap snapshot from the current process and write it to disk.
+ *
+ * `v8.getHeapSnapshot()` returns a Readable stream whose producer is V8's
+ * internal snapshot generator. Piping it through `node:stream/promises`'
+ * `pipeline` propagates backpressure end-to-end, so even a multi-gigabyte
+ * heap is written without buffering the serialized snapshot in memory.
+ * Nothing is exposed over a debugger port.
+ */
+export async function captureHeapSnapshot(filePath: string): Promise {
+ await mkdir(dirname(filePath), { recursive: true });
+ await pipeline(getHeapSnapshot(), createWriteStream(filePath));
+}
diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts
index d255dc1d3a..53e837371d 100644
--- a/packages/cli/src/utils/userStartupWarnings.test.ts
+++ b/packages/cli/src/utils/userStartupWarnings.test.ts
@@ -19,11 +19,11 @@ import {
} from '@google/gemini-cli-core';
// Mock os.homedir to control the home directory in tests
-vi.mock('os', async (importOriginal) => {
+vi.mock('node:os', async (importOriginal) => {
const actualOs = await importOriginal();
return {
...actualOs,
- homedir: vi.fn(),
+ homedir: vi.fn(() => actualOs.homedir()),
};
});
@@ -32,7 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal();
return {
...actual,
- homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
@@ -66,6 +65,7 @@ describe('getUserStartupWarnings', () => {
afterEach(async () => {
await fs.rm(testRootDir, { recursive: true, force: true });
+ vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -98,6 +98,54 @@ describe('getUserStartupWarnings', () => {
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
+ it('should not return a warning when running in a subdirectory of home', async () => {
+ const subDir = path.join(homeDir, 'projects', 'my-app');
+ await fs.mkdir(subDir, { recursive: true });
+ const warnings = await getUserStartupWarnings({}, subDir);
+ expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
+ });
+
+ it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => {
+ const realHome = path.join(testRootDir, 'real-home');
+ await fs.mkdir(realHome, { recursive: true });
+ const symlinkedHome = path.join(testRootDir, 'symlinked-home');
+ await fs.symlink(realHome, symlinkedHome);
+ vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
+
+ const subDir = path.join(symlinkedHome, 'projects');
+ await fs.mkdir(subDir, { recursive: true });
+ const warnings = await getUserStartupWarnings({}, subDir);
+ expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
+ });
+
+ it('should return a warning when home directory is a symlink and running in it', async () => {
+ const realHome = path.join(testRootDir, 'real-home2');
+ await fs.mkdir(realHome, { recursive: true });
+ const symlinkedHome = path.join(testRootDir, 'symlinked-home2');
+ await fs.symlink(realHome, symlinkedHome);
+ vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
+
+ const warnings = await getUserStartupWarnings({}, symlinkedHome);
+ expect(warnings).toContainEqual(
+ expect.objectContaining({
+ id: 'home-directory',
+ message: expect.stringContaining(
+ 'Warning you are running Gemini CLI in your home directory',
+ ),
+ priority: WarningPriority.Low,
+ }),
+ );
+ });
+
+ it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => {
+ const projectDir = path.join(testRootDir, 'project');
+ await fs.mkdir(projectDir, { recursive: true });
+ vi.stubEnv('GEMINI_CLI_HOME', projectDir);
+
+ const warnings = await getUserStartupWarnings({}, projectDir);
+ expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
+ });
+
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts
index 549b62f859..28858d5629 100644
--- a/packages/cli/src/utils/userStartupWarnings.ts
+++ b/packages/cli/src/utils/userStartupWarnings.ts
@@ -5,10 +5,10 @@
*/
import fs from 'node:fs/promises';
+import { homedir as osHomedir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
- homedir,
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
@@ -39,10 +39,10 @@ const homeDirectoryCheck: WarningCheck = {
try {
const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(workspaceRoot),
- fs.realpath(homedir()),
+ fs.realpath(osHomedir()),
]);
- if (workspaceRealPath === homeRealPath) {
+ if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) {
// If folder trust is enabled and the user trusts the home directory, don't show the warning.
if (
isFolderTrustEnabled(settings) &&
diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts
index 8f5a24a881..1f24e06c6c 100644
--- a/packages/core/src/agent/legacy-agent-session.test.ts
+++ b/packages/core/src/agent/legacy-agent-session.test.ts
@@ -200,7 +200,6 @@ describe('LegacyAgentSession', () => {
expect.any(AbortSignal),
'test-prompt',
undefined,
- false,
'raw input',
);
diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts
index 5fb024378e..4cf2e4d7f6 100644
--- a/packages/core/src/agent/legacy-agent-session.ts
+++ b/packages/core/src/agent/legacy-agent-session.ts
@@ -196,7 +196,6 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._abortController.signal,
this._promptId,
undefined,
- false,
currentDisplayContent,
);
currentDisplayContent = undefined;
diff --git a/packages/core/src/agents/generalist-agent.test.ts b/packages/core/src/agents/generalist-agent.test.ts
index b297d2726f..5514c178cb 100644
--- a/packages/core/src/agents/generalist-agent.test.ts
+++ b/packages/core/src/agents/generalist-agent.test.ts
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeneralistAgent } from './generalist-agent.js';
import { makeFakeConfig } from '../test-utils/config.js';
+import { ApprovalMode } from '../policy/types.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { AgentRegistry } from './registry.js';
@@ -54,4 +55,35 @@ describe('GeneralistAgent', () => {
// Ensure it's non-interactive
expect(agent.promptConfig.systemPrompt).toContain('non-interactive');
});
+
+ it('should adjust its description dynamically based on the approval mode', () => {
+ const config = makeFakeConfig();
+ const mockToolRegistry = {
+ getAllToolNames: () => ['tool1'],
+ } as unknown as ToolRegistry;
+ Object.defineProperty(config, 'toolRegistry', {
+ get: () => mockToolRegistry,
+ });
+ Object.defineProperty(config, 'config', {
+ get() {
+ return this;
+ },
+ });
+
+ const agent = GeneralistAgent(config);
+
+ // Default description
+ vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
+ expect(agent.description).toContain('batch refactoring/error fixing');
+ expect(agent.description).not.toContain(
+ 'large-scale investigation and batch planning',
+ );
+
+ // Plan Mode description
+ vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.PLAN);
+ expect(agent.description).not.toContain('batch refactoring/error fixing');
+ expect(agent.description).toContain(
+ 'large-scale investigation and batch planning',
+ );
+ });
});
diff --git a/packages/core/src/agents/generalist-agent.ts b/packages/core/src/agents/generalist-agent.ts
index 26eb2aa8d5..f8e2e5faa4 100644
--- a/packages/core/src/agents/generalist-agent.ts
+++ b/packages/core/src/agents/generalist-agent.ts
@@ -9,6 +9,8 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import type { LocalAgentDefinition } from './types.js';
+import { ApprovalMode } from '../policy/types.js';
+
const GeneralistAgentSchema = z.object({
response: z.string().describe('The final response from the agent.'),
});
@@ -23,8 +25,14 @@ export const GeneralistAgent = (
kind: 'local',
name: 'generalist',
displayName: 'Generalist Agent',
- description:
- 'A general-purpose AI agent with access to all tools. Highly recommended for tasks that are turn-intensive or involve processing large amounts of data. Use this to keep the main session history lean and efficient. Excellent for: batch refactoring/error fixing across multiple files, running commands with high-volume output, and speculative investigations.',
+ get description() {
+ const baseDescription =
+ 'A general-purpose AI agent with access to all tools. Highly recommended for tasks that are turn-intensive or involve processing large amounts of data. Use this to keep the main session history lean and efficient. Excellent for: ';
+ if (context.config.getApprovalMode() === ApprovalMode.PLAN) {
+ return `${baseDescription}large-scale investigation and batch planning across multiple files.`;
+ }
+ return `${baseDescription}batch refactoring/error fixing across multiple files, running commands with high-volume output, and speculative investigations.`;
+ },
inputConfig: {
inputSchema: {
type: 'object',
diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts
index 26f0cc88e3..c9cacf79f6 100644
--- a/packages/core/src/agents/local-executor.test.ts
+++ b/packages/core/src/agents/local-executor.test.ts
@@ -105,6 +105,7 @@ import {
type OutputConfig,
SubagentActivityErrorType,
} from './types.js';
+import { ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
@@ -207,12 +208,20 @@ vi.mock('../config/scoped-config.js', async (importOriginal) => {
...actual,
runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext),
createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext),
+ runWithScopedAutoMemoryExtractionWriteAccess: vi.fn(
+ actual.runWithScopedAutoMemoryExtractionWriteAccess,
+ ),
+ runWithScopedMemoryInboxAccess: vi.fn(
+ actual.runWithScopedMemoryInboxAccess,
+ ),
};
});
import {
runWithScopedWorkspaceContext,
createScopedWorkspaceContext,
+ runWithScopedAutoMemoryExtractionWriteAccess,
+ runWithScopedMemoryInboxAccess,
} from '../config/scoped-config.js';
const mockedRunWithScopedWorkspaceContext = vi.mocked(
runWithScopedWorkspaceContext,
@@ -220,6 +229,12 @@ const mockedRunWithScopedWorkspaceContext = vi.mocked(
const mockedCreateScopedWorkspaceContext = vi.mocked(
createScopedWorkspaceContext,
);
+const mockedRunWithScopedMemoryInboxAccess = vi.mocked(
+ runWithScopedMemoryInboxAccess,
+);
+const mockedRunWithScopedAutoMemoryExtractionWriteAccess = vi.mocked(
+ runWithScopedAutoMemoryExtractionWriteAccess,
+);
const MockedGeminiChat = vi.mocked(GeminiChat);
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
@@ -421,6 +436,8 @@ describe('LocalAgentExecutor', () => {
mockedLogAgentFinish.mockReset();
mockedRunWithScopedWorkspaceContext.mockClear();
mockedCreateScopedWorkspaceContext.mockClear();
+ mockedRunWithScopedMemoryInboxAccess.mockClear();
+ mockedRunWithScopedAutoMemoryExtractionWriteAccess.mockClear();
mockedPromptIdContext.getStore.mockReset();
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
@@ -940,6 +957,52 @@ describe('LocalAgentExecutor', () => {
expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce();
});
+ it('should use runWithScopedMemoryInboxAccess when memoryInboxAccess is set', async () => {
+ const definition = createTestDefinition();
+ definition.memoryInboxAccess = true;
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ mockConfig,
+ onActivity,
+ );
+
+ mockModelResponse([
+ {
+ name: COMPLETE_TASK_TOOL_NAME,
+ args: { finalResult: 'done' },
+ id: 'c1',
+ },
+ ]);
+
+ await executor.run({ goal: 'test' }, signal);
+
+ expect(mockedRunWithScopedMemoryInboxAccess).toHaveBeenCalledOnce();
+ });
+
+ it('should use the extraction write scope when autoMemoryExtractionWriteAccess is set', async () => {
+ const definition = createTestDefinition();
+ definition.autoMemoryExtractionWriteAccess = true;
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ mockConfig,
+ onActivity,
+ );
+
+ mockModelResponse([
+ {
+ name: COMPLETE_TASK_TOOL_NAME,
+ args: { finalResult: 'done' },
+ id: 'c1',
+ },
+ ]);
+
+ await executor.run({ goal: 'test' }, signal);
+
+ expect(
+ mockedRunWithScopedAutoMemoryExtractionWriteAccess,
+ ).toHaveBeenCalledOnce();
+ });
+
it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
@@ -961,6 +1024,10 @@ describe('LocalAgentExecutor', () => {
expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled();
expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled();
+ expect(mockedRunWithScopedMemoryInboxAccess).not.toHaveBeenCalled();
+ expect(
+ mockedRunWithScopedAutoMemoryExtractionWriteAccess,
+ ).not.toHaveBeenCalled();
});
});
@@ -1276,6 +1343,42 @@ describe('LocalAgentExecutor', () => {
expect(mockScheduleAgentTools).toHaveBeenCalledTimes(2);
});
+ it('should inject Plan Mode context into the system prompt when in Plan Mode', async () => {
+ const definition = createTestDefinition([LS_TOOL_NAME], {}, 'none');
+ vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
+ ApprovalMode.PLAN,
+ );
+ vi.spyOn(mockConfig.storage, 'getPlansDir').mockReturnValue(
+ '/mock/plans',
+ );
+
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ mockConfig,
+ onActivity,
+ );
+
+ // Turn 1: Model calls complete_task immediately
+ mockModelResponse(
+ [
+ {
+ name: COMPLETE_TASK_TOOL_NAME,
+ args: { result: 'Plan done' },
+ id: 'call1',
+ },
+ ],
+ 'Task finished.',
+ );
+
+ await executor.run({ goal: 'Do plan' }, signal);
+
+ const systemInstruction = MockedGeminiChat.mock.calls[0][1];
+ expect(systemInstruction).toContain('Execution Constraints');
+ expect(systemInstruction).toContain(
+ 'You are currently operating in Plan Mode. Your write tools are globally restricted to only modifying plan (.md) files in the plans directory: /mock/plans/',
+ );
+ });
+
it('should error immediately if the model stops tools without calling complete_task (Protocol Violation)', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts
index ca856d8b8e..c3572edb11 100644
--- a/packages/core/src/agents/local-executor.ts
+++ b/packages/core/src/agents/local-executor.ts
@@ -6,6 +6,7 @@
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { reportError } from '../utils/errorReporting.js';
+import { ApprovalMode } from '../policy/types.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import {
type Content,
@@ -76,6 +77,8 @@ import {
import type { InjectionSource } from '../config/injectionService.js';
import {
createScopedWorkspaceContext,
+ runWithScopedAutoMemoryExtractionWriteAccess,
+ runWithScopedMemoryInboxAccess,
runWithScopedWorkspaceContext,
} from '../config/scoped-config.js';
import { CompleteTaskTool } from '../tools/complete-task.js';
@@ -528,21 +531,34 @@ export class LocalAgentExecutor {
* @returns A promise that resolves to the agent's final output.
*/
async run(inputs: AgentInputs, signal: AbortSignal): Promise {
- // If the agent definition declares additional workspace directories,
- // wrap execution in a scoped workspace context. All calls to
- // Config.getWorkspaceContext() within this scope will see the extended
- // directories, without mutating the shared Config.
- const dirs = this.definition.workspaceDirectories;
- if (dirs && dirs.length > 0) {
- const scopedCtx = createScopedWorkspaceContext(
- this.context.config.getWorkspaceContext(),
- dirs,
- );
- return runWithScopedWorkspaceContext(scopedCtx, () =>
- this.runInternal(inputs, signal),
- );
+ const runWithWorkspaceScope = () => {
+ // If the agent definition declares additional workspace directories,
+ // wrap execution in a scoped workspace context. All calls to
+ // Config.getWorkspaceContext() within this scope will see the extended
+ // directories, without mutating the shared Config.
+ const dirs = this.definition.workspaceDirectories;
+ if (dirs && dirs.length > 0) {
+ const scopedCtx = createScopedWorkspaceContext(
+ this.context.config.getWorkspaceContext(),
+ dirs,
+ );
+ return runWithScopedWorkspaceContext(scopedCtx, () =>
+ this.runInternal(inputs, signal),
+ );
+ }
+ return this.runInternal(inputs, signal);
+ };
+
+ const runWithInboxScope = () =>
+ this.definition.memoryInboxAccess
+ ? runWithScopedMemoryInboxAccess(runWithWorkspaceScope)
+ : runWithWorkspaceScope();
+
+ if (this.definition.autoMemoryExtractionWriteAccess) {
+ return runWithScopedAutoMemoryExtractionWriteAccess(runWithInboxScope);
}
- return this.runInternal(inputs, signal);
+
+ return runWithInboxScope();
}
private async runInternal(
@@ -1355,6 +1371,12 @@ export class LocalAgentExecutor {
const dirContext = await getDirectoryContextString(this.context.config);
finalPrompt += `\n\n# Environment Context\n${dirContext}`;
+ const approvalMode = this.context.config.getApprovalMode();
+ if (approvalMode === ApprovalMode.PLAN) {
+ const plansDir = this.context.config.storage.getPlansDir();
+ finalPrompt += `\n\n# Execution Constraints\nYou are currently operating in Plan Mode. Your write tools are globally restricted to only modifying plan (.md) files in the plans directory: ${plansDir}/. Do not attempt to modify source code directly.`;
+ }
+
// Append standard rules for non-interactive execution.
finalPrompt += `
Important Rules:
diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts
index 3d45be1f94..7618440957 100644
--- a/packages/core/src/agents/registry.test.ts
+++ b/packages/core/src/agents/registry.test.ts
@@ -459,7 +459,7 @@ describe('AgentRegistry', () => {
await registry.initialize();
- // Verify ackService was called with the URL, not the file hash
+ // Verify ackService was called with the raw URL to avoid breaking changes
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
expect.anything(),
'RemoteAgent',
@@ -467,7 +467,6 @@ describe('AgentRegistry', () => {
);
// Also verify that the agent's metadata was updated to use the URL as hash
- // Use getDefinition because registerAgent might have been called
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
'https://example.com/card',
);
diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts
index 32aee9d2c5..b9d434e4c7 100644
--- a/packages/core/src/agents/registry.ts
+++ b/packages/core/src/agents/registry.ts
@@ -8,7 +8,11 @@ import * as crypto from 'node:crypto';
import { Storage } from '../config/storage.js';
import { CoreEvent, coreEvents } from '../utils/events.js';
import type { AgentOverride, Config } from '../config/config.js';
-import type { AgentDefinition, LocalAgentDefinition } from './types.js';
+import {
+ type AgentDefinition,
+ type LocalAgentDefinition,
+ type AgentReloadSummary,
+} from './types.js';
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
@@ -80,13 +84,53 @@ export class AgentRegistry {
/**
* Clears the current registry and re-scans for agents.
*/
- async reload(): Promise {
+ async reload(): Promise {
+ const previousAgents = new Map(this.agents);
+ const reloadErrors: string[] = [];
+
this.config.getA2AClientManager()?.clearCache();
await this.config.reloadAgents();
- this.agents.clear();
- this.allDefinitions.clear();
- await this.loadAgents();
+ await this.loadAgents(reloadErrors);
+
+ const currentAgents = Array.from(this.agents.values());
+ const newAgents: string[] = [];
+ const updatedAgents: string[] = [];
+ const deletedAgents: string[] = [];
+ let localCount = 0;
+ let remoteCount = 0;
+
+ for (const agent of currentAgents) {
+ if (agent.kind === 'local') {
+ localCount++;
+ } else if (agent.kind === 'remote') {
+ remoteCount++;
+ }
+
+ const prev = previousAgents.get(agent.name);
+ if (!prev) {
+ newAgents.push(agent.name);
+ } else if (agent.metadata?.hash !== prev.metadata?.hash) {
+ updatedAgents.push(agent.name);
+ }
+ }
+
+ for (const prevName of previousAgents.keys()) {
+ if (!this.agents.has(prevName)) {
+ deletedAgents.push(prevName);
+ }
+ }
+
coreEvents.emitAgentsRefreshed();
+
+ return {
+ totalLoaded: currentAgents.length,
+ localCount,
+ remoteCount,
+ newAgents,
+ updatedAgents,
+ deletedAgents,
+ errors: reloadErrors,
+ };
}
/**
@@ -113,7 +157,7 @@ export class AgentRegistry {
coreEvents.off(CoreEvent.ModelChanged, this.onModelChanged);
}
- private async loadAgents(): Promise {
+ private async loadAgents(errors?: string[]): Promise {
this.agents.clear();
this.allDefinitions.clear();
this.loadBuiltInAgents();
@@ -132,21 +176,20 @@ export class AgentRegistry {
debugLogger.warn(
`[AgentRegistry] Error loading user agent: ${error.message}`,
);
- coreEvents.emitFeedback('error', `Agent loading error: ${error.message}`);
+ const msg = `Agent loading error: ${error.message}`;
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
}
await Promise.allSettled(
userAgents.agents.map(async (agent) => {
try {
- await this.registerAgent(agent);
+ this.ensureRemoteAgentHash(agent);
+ await this.registerAgent(agent, errors);
} catch (e) {
- debugLogger.warn(
- `[AgentRegistry] Error registering user agent "${agent.name}":`,
- e,
- );
- coreEvents.emitFeedback(
- 'error',
- `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
- );
+ const msg = `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
+ debugLogger.warn(`[AgentRegistry] ${msg}`, e);
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -159,10 +202,9 @@ export class AgentRegistry {
const projectAgentsDir = this.config.storage.getProjectAgentsDir();
const projectAgents = await loadAgentsFromDirectory(projectAgentsDir);
for (const error of projectAgents.errors) {
- coreEvents.emitFeedback(
- 'error',
- `Agent loading error: ${error.message}`,
- );
+ const msg = `Agent loading error: ${error.message}`;
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
}
const ackService = this.config.getAcknowledgedAgentsService();
@@ -171,21 +213,7 @@ export class AgentRegistry {
const agentsToRegister: AgentDefinition[] = [];
for (const agent of projectAgents.agents) {
- // If it's a remote agent, use the agentCardUrl as the hash.
- // This allows multiple remote agents in a single file to be tracked independently.
- if (agent.kind === 'remote') {
- if (!agent.metadata) {
- agent.metadata = {};
- }
- agent.metadata.hash =
- agent.agentCardUrl ??
- (agent.agentCardJson
- ? crypto
- .createHash('sha256')
- .update(agent.agentCardJson)
- .digest('hex')
- : undefined);
- }
+ this.ensureRemoteAgentHash(agent);
if (!agent.metadata?.hash) {
agentsToRegister.push(agent);
@@ -212,16 +240,12 @@ export class AgentRegistry {
await Promise.allSettled(
agentsToRegister.map(async (agent) => {
try {
- await this.registerAgent(agent);
+ await this.registerAgent(agent, errors);
} catch (e) {
- debugLogger.warn(
- `[AgentRegistry] Error registering project agent "${agent.name}":`,
- e,
- );
- coreEvents.emitFeedback(
- 'error',
- `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
- );
+ const msg = `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
+ debugLogger.warn(`[AgentRegistry] ${msg}`, e);
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -238,16 +262,12 @@ export class AgentRegistry {
await Promise.allSettled(
extension.agents.map(async (agent) => {
try {
- await this.registerAgent(agent);
+ await this.registerAgent(agent, errors);
} catch (e) {
- debugLogger.warn(
- `[AgentRegistry] Error registering extension agent "${agent.name}":`,
- e,
- );
- coreEvents.emitFeedback(
- 'error',
- `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
- );
+ const msg = `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
+ debugLogger.warn(`[AgentRegistry] ${msg}`, e);
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -314,11 +334,12 @@ export class AgentRegistry {
*/
protected async registerAgent(
definition: AgentDefinition,
+ errors?: string[],
): Promise {
if (definition.kind === 'local') {
this.registerLocalAgent(definition);
} else if (definition.kind === 'remote') {
- await this.registerRemoteAgent(definition);
+ await this.registerRemoteAgent(definition, errors);
}
}
@@ -416,6 +437,7 @@ export class AgentRegistry {
*/
protected async registerRemoteAgent(
definition: AgentDefinition,
+ errors?: string[],
): Promise {
if (definition.kind !== 'remote') {
return;
@@ -544,17 +566,14 @@ export class AgentRegistry {
this.addAgentPolicy(definition);
} catch (e) {
// Surface structured, user-friendly error messages for known failure modes.
+ let msg: string;
if (e instanceof A2AAgentError) {
- coreEvents.emitFeedback(
- 'error',
- `[${definition.name}] ${e.userMessage}`,
- );
+ msg = `[${definition.name}] ${e.userMessage}`;
} else {
- coreEvents.emitFeedback(
- 'error',
- `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`,
- );
+ msg = `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`;
}
+ errors?.push(msg);
+ coreEvents.emitFeedback('error', msg);
debugLogger.warn(
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
e,
@@ -704,4 +723,28 @@ export class AgentRegistry {
getDiscoveredDefinition(name: string): AgentDefinition | undefined {
return this.allDefinitions.get(name);
}
+
+ /**
+ * Ensures that remote agents have a content-based hash for trust verification and change detection.
+ */
+ private ensureRemoteAgentHash(agent: AgentDefinition): void {
+ if (agent.kind !== 'remote') {
+ return;
+ }
+
+ if (!agent.metadata) {
+ agent.metadata = {};
+ }
+
+ // To avoid a breaking change for existing users, we continue to use
+ // the raw URL as the hash for URL-based remote agents.
+ if (agent.agentCardUrl) {
+ agent.metadata.hash = agent.agentCardUrl;
+ } else if (agent.agentCardJson) {
+ agent.metadata.hash = crypto
+ .createHash('sha256')
+ .update(agent.agentCardJson)
+ .digest('hex');
+ }
+ }
}
diff --git a/packages/core/src/agents/skill-extraction-agent.test.ts b/packages/core/src/agents/skill-extraction-agent.test.ts
index 280cbc33e3..7e5251d053 100644
--- a/packages/core/src/agents/skill-extraction-agent.test.ts
+++ b/packages/core/src/agents/skill-extraction-agent.test.ts
@@ -12,6 +12,7 @@ import {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
+ SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -34,6 +35,8 @@ describe('SkillExtractionAgent', () => {
expect(agent.name).toBe('confucius');
expect(agent.displayName).toBe('Skill Extractor');
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
+ expect(agent.memoryInboxAccess).toBe(true);
+ expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
@@ -44,6 +47,7 @@ describe('SkillExtractionAgent', () => {
GREP_TOOL_NAME,
]),
);
+ expect(agent.toolConfig?.tools).not.toContain(SHELL_TOOL_NAME);
});
it('should default to no skill unless recurrence and durability are proven', () => {
@@ -69,6 +73,104 @@ describe('SkillExtractionAgent', () => {
expect(prompt).toContain('cannot survive renaming the specific');
});
+ it('should require all memory updates to go through .inbox//*.patch for review', () => {
+ const prompt = SkillExtractionAgent(
+ skillsDir,
+ sessionIndex,
+ existingSkillsSummary,
+ '/tmp/memory',
+ ).promptConfig.systemPrompt;
+
+ expect(prompt).toContain(
+ 'ALL memory updates are expressed as unified diff `.patch` files',
+ );
+ expect(prompt).toContain('EXACTLY ONE canonical patch file per kind');
+ expect(prompt).toContain('extraction.patch');
+ expect(prompt).not.toContain('MEMORY.patch');
+ expect(prompt).not.toContain('verify-workflow.patch');
+ expect(prompt).toContain('IMPORTANT ā incremental updates');
+ expect(prompt).toContain(
+ 'REWRITE that file by combining its existing hunks with your new',
+ );
+ expect(prompt).toContain('private ->');
+ expect(prompt).toContain('global ->');
+ expect(prompt).toContain(
+ 'the target MUST be exactly the single global personal memory',
+ );
+ expect(prompt).toContain('~/.gemini/GEMINI.md');
+ expect(prompt).not.toContain('memory.md');
+ expect(prompt).not.toContain('and siblings');
+ expect(prompt).toContain(
+ 'Project/workspace shared instructions (GEMINI.md and similar files',
+ );
+ expect(prompt).toContain('MEMORY PATCH FORMAT (STRICT)');
+ expect(prompt).toContain('--- /dev/null');
+ expect(prompt).toContain('NEVER directly edit MEMORY.md');
+ expect(prompt).toContain(
+ 'Every patch you write is held for /memory inbox review.',
+ );
+ expect(prompt).toContain('the user must approve each patch');
+
+ // The MEMORY.md-as-index discipline: sibling creations should pair with
+ // a MEMORY.md update hunk; the inbox apply step auto-bundles a generic
+ // pointer if the agent forgets, but the agent should write its own.
+ expect(prompt).toContain('PRIVATE MEMORY: MEMORY.md IS THE INDEX');
+ expect(prompt).toContain(
+ 'when you create a new sibling .md file, your patch SHOULD',
+ );
+ expect(prompt).toContain('a SECOND HUNK that updates MEMORY.md');
+ expect(prompt).toContain('inbox apply step');
+ expect(prompt).toContain('auto-bundle a generic pointer');
+
+ // Pointer paths must be ABSOLUTE ā the runtime agent reads them directly.
+ expect(prompt).toContain('IMPORTANT ā pointer paths must be ABSOLUTE');
+ expect(prompt).toContain('Always write the full path');
+ // The example pointer in the prompt also uses the absolute path.
+ expect(prompt).toContain(`+- See /tmp/memory/.md for`);
+ });
+
+ it('surfaces existing inbox patches in the initial query when present', () => {
+ const pendingInbox = [
+ '## private (1)',
+ '',
+ '### extraction.patch',
+ '```',
+ '--- /dev/null',
+ '+++ /tmp/memory/MEMORY.md',
+ '@@ -0,0 +1,1 @@',
+ '+- previously-extracted fact',
+ '```',
+ ].join('\n');
+
+ const agentWithInbox = SkillExtractionAgent(
+ skillsDir,
+ sessionIndex,
+ existingSkillsSummary,
+ '/tmp/memory',
+ pendingInbox,
+ );
+ const query = agentWithInbox.promptConfig.query ?? '';
+
+ expect(query).toContain('# Pending Memory Inbox');
+ expect(query).toContain('extraction.patch');
+ expect(query).toContain('previously-extracted fact');
+ expect(query).toContain(
+ 'REWRITE that patch (overwrite the same path) with',
+ );
+ });
+
+ it('omits the pending inbox section when nothing is pending', () => {
+ const agentEmpty = SkillExtractionAgent(
+ skillsDir,
+ sessionIndex,
+ existingSkillsSummary,
+ '/tmp/memory',
+ '',
+ );
+ const query = agentEmpty.promptConfig.query ?? '';
+ expect(query).not.toContain('# Pending Memory Inbox');
+ });
+
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
const query = agent.promptConfig.query ?? '';
@@ -86,7 +188,10 @@ describe('SkillExtractionAgent', () => {
'Only write a skill if the evidence shows a durable, recurring workflow',
);
expect(query).toContain(
- 'If recurrence or future reuse is unclear, create no skill and explain why.',
+ 'Only write memory if it would clearly help a future session.',
+ );
+ expect(query).toContain(
+ 'If recurrence, durability, or future reuse is unclear, create no artifact and explain why.',
);
});
});
diff --git a/packages/core/src/agents/skill-extraction-agent.ts b/packages/core/src/agents/skill-extraction-agent.ts
index eea2a4727d..b84a46ba17 100644
--- a/packages/core/src/agents/skill-extraction-agent.ts
+++ b/packages/core/src/agents/skill-extraction-agent.ts
@@ -13,7 +13,6 @@ import {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
- SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -21,20 +20,21 @@ import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
const SkillExtractionSchema = z.object({
response: z
.string()
- .describe('A summary of the skills extracted or updated.'),
+ .describe('A summary of the memories or skills extracted or updated.'),
});
/**
* Builds the system prompt for the skill extraction agent.
*/
-function buildSystemPrompt(skillsDir: string): string {
+function buildSystemPrompt(skillsDir: string, memoryDir: string): string {
return [
- 'You are a Skill Extraction Agent.',
+ 'You are an Auto Memory Extraction Agent.',
'',
- 'Your job: analyze past conversation sessions and extract reusable skills that will help',
- 'future agents work more efficiently. You write SKILL.md files to a specific directory.',
+ 'Your job: analyze past conversation sessions and extract durable memory candidates',
+ 'and reusable skills that will help future agents work more efficiently.',
'',
'The goal is to help future agents:',
+ '- remember durable project facts, preferences, and workflow constraints',
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
'- reuse proven workflows and verification checklists',
'- avoid known failure modes and landmines',
@@ -48,8 +48,131 @@ function buildSystemPrompt(skillsDir: string): string {
'- Evidence-based only: do not invent facts or claim verification that did not happen.',
'- Redact secrets: never store tokens/keys/passwords; replace with [REDACTED].',
'- Do not copy large tool outputs. Prefer compact summaries + exact error snippets.',
- ` Write all files under this directory ONLY: ${skillsDir}`,
- ' NEVER write files outside this directory. You may read session files from the paths provided in the index.',
+ `- Write all files under this memory work directory ONLY: ${memoryDir}`,
+ `- Reusable skill candidates go under: ${skillsDir}`,
+ `- Reviewable memory candidates go under: ${memoryDir}/.inbox`,
+ ' NEVER write files outside the memory work directory. You may read session files from the paths provided in the index.',
+ '',
+ '============================================================',
+ 'MEMORY OUTPUTS',
+ '============================================================',
+ '',
+ 'ALL memory updates are expressed as unified diff `.patch` files. There is',
+ `EXACTLY ONE canonical patch file per kind: ${memoryDir}/.inbox//extraction.patch`,
+ 'where is one of:',
+ '- private -> targets must live under the project memory directory',
+ ` (${memoryDir}). Use this for project-scoped private memory.`,
+ '- global -> the target MUST be exactly the single global personal memory',
+ ' file ~/.gemini/GEMINI.md. No other files in ~/.gemini/ are',
+ ' writeable; sibling .md files do not exist for the global tier.',
+ '',
+ 'IMPORTANT ā incremental updates:',
+ '- Before writing a new patch, check if "# Pending Memory Inbox" (above)',
+ ' already lists an `extraction.patch` for the same kind.',
+ '- If yes: REWRITE that file by combining its existing hunks with your new',
+ ' ones (overwrite the same path with the merged multi-hunk patch). Do NOT',
+ ' create separate `topic-a.patch`, `topic-b.patch` files; everything goes',
+ ' in one canonical `extraction.patch` per kind.',
+ '- If no: write a new `extraction.patch` with all your hunks.',
+ '',
+ 'Project/workspace shared instructions (GEMINI.md and similar files under the',
+ 'project root) are NOT auto-extractable. They are managed by humans only; do',
+ 'not write patches that target files under the project root.',
+ '',
+ 'NEVER directly edit MEMORY.md, GEMINI.md, ~/.gemini/GEMINI.md, settings,',
+ 'credentials, or any file outside the memory work directory. The only way to',
+ 'update memory is via a `.patch` file in the appropriate `.inbox//` folder.',
+ '',
+ 'Every patch you write is held for /memory inbox review. Nothing is applied',
+ 'automatically; the user must approve each patch before it touches active files.',
+ '',
+ 'Private memory is for durable facts, preferences, decisions, and project context.',
+ 'Skills are only for reusable procedures. If both apply, avoid duplicating the same content.',
+ 'Default to no-op. Prefer 0-5 memory patches and 0-2 skills per run.',
+ '',
+ '============================================================',
+ 'PRIVATE MEMORY: MEMORY.md IS THE INDEX (CRITICAL)',
+ '============================================================',
+ '',
+ `In (${memoryDir}), only MEMORY.md is auto-loaded into future`,
+ 'agent contexts. Sibling .md files (e.g. verify-workflow.md, design-doc.md)',
+ 'are loaded ON DEMAND by the runtime agent via read_file ONLY when MEMORY.md',
+ 'references them.',
+ '',
+ 'Therefore, when you create a new sibling .md file, your patch SHOULD',
+ 'include a SECOND HUNK that updates MEMORY.md to add a one-line pointer',
+ 'to the new file. The pointer is what makes the sibling discoverable to',
+ 'future agents.',
+ '',
+ 'IMPORTANT ā pointer paths must be ABSOLUTE. Future agents `read_file`',
+ `directly off the pointer line, so the path must resolve without knowing`,
+ `. Always write the full path (${memoryDir}/.md), never`,
+ 'just the basename. The auto-bundle fallback also writes absolute paths.',
+ '',
+ 'If you forget to include the MEMORY.md pointer, the inbox apply step',
+ `will auto-bundle a generic pointer (\`- See ${memoryDir}/.md for ...\`)`,
+ 'so the sibling is at least discoverable. But that auto-pointer is dumb ā',
+ 'write the proper paired hunk yourself so MEMORY.md gets a meaningful',
+ 'summary.',
+ '',
+ 'Correct shape for "create a new sibling" patch:',
+ '',
+ ' --- /dev/null',
+ ` +++ ${memoryDir}/.md`,
+ ' @@ -0,0 +1,N @@',
+ ' +# ',
+ ' +...',
+ '',
+ ` --- ${memoryDir}/MEMORY.md`,
+ ` +++ ${memoryDir}/MEMORY.md`,
+ ' @@ -,3 +,4 @@',
+ ' ',
+ ' ',
+ ' ',
+ ` +- See ${memoryDir}/.md for .`,
+ '',
+ 'For brief facts (a few lines), prefer adding the entry directly to MEMORY.md',
+ 'as a single-hunk patch ā no sibling file needed. Only spawn a sibling file',
+ 'when the content has substantial detail (multiple sections, procedures, etc.).',
+ '',
+ '============================================================',
+ 'MEMORY PATCH FORMAT (STRICT)',
+ '============================================================',
+ '',
+ 'Always read the target file first with read_file (or skip the read if the file',
+ 'definitely does not exist yet) so the patch context lines match exactly.',
+ '',
+ 'Use one of these two unified diff shapes inside each `.patch` file:',
+ '',
+ '1. Update an existing file:',
+ '',
+ ' --- /absolute/path/to/target.md',
+ ' +++ /absolute/path/to/target.md',
+ ' @@ -, +, @@',
+ ' ',
+ ' -',
+ ' +',
+ ' ',
+ '',
+ '2. Create a brand-new file (no existing target):',
+ '',
+ ' --- /dev/null',
+ ' +++ /absolute/path/to/new-target.md',
+ ' @@ -0,0 +1, @@',
+ ' +',
+ ' +',
+ '',
+ 'Patch rules:',
+ '- Use the EXACT absolute file path in BOTH --- and +++ headers (NO `a/`/`b/` prefixes).',
+ '- For updates, both headers must be the SAME absolute path.',
+ '- Include 3 lines of context around each change for updates.',
+ '- Line counts in @@ headers MUST be accurate.',
+ '- One `.patch` file may include multiple hunks across multiple files in the same kind.',
+ '- The patch FILENAME under .inbox// MUST be the canonical',
+ ' `extraction.patch`; the headers determine the actual target file(s).',
+ '- Patches that fail validation or fail to apply cleanly are discarded silently.',
+ "- The header path must resolve under the kind's allowed root (see above) or the",
+ ' patch will be rejected.',
'',
'============================================================',
'NO-OP / MINIMUM SIGNAL GATE',
@@ -212,8 +335,7 @@ function buildSystemPrompt(skillsDir: string): string {
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
- ' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
- ' skill directories and package_skill.cjs to validate finished skills.',
+ ' writing SKILL.md files.',
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
' installation prompts, iteration loops). Use only its format and quality guidance.',
@@ -228,15 +350,19 @@ function buildSystemPrompt(skillsDir: string): string {
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
' why it is not generic.',
- '8. Write new SKILL.md files or update existing ones in your directory.',
- ' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
- ' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
- '9. Write COMPLETE files ā never partially update a SKILL.md.',
+ '8. For memory candidates: read the target file first (or confirm it does not exist),',
+ ' then write a `.patch` file under the appropriate .inbox// directory using',
+ ' the format in MEMORY PATCH FORMAT. Prefer updating existing memory files over',
+ ' duplicating facts. Keep patches small and focused.',
+ '9. Write new SKILL.md files or update existing ones in your skills directory.',
+ ' Use write_file/edit directly; shell commands are intentionally unavailable in this background flow.',
+ ' For skills that live OUTSIDE your skills directory, write a `.patch` file there instead (see UPDATING EXISTING SKILLS).',
+ '10. Write COMPLETE SKILL.md files ā never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
- 'should read 0-3 sessions and create 0 skills.',
- 'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
+ 'should read 0-3 sessions and create few or no artifacts.',
+ 'Do not explore the codebase. Work only with the session index, session files, and the memory work directory.',
].join('\n');
}
@@ -253,12 +379,20 @@ export const SkillExtractionAgent = (
skillsDir: string,
sessionIndex: string,
existingSkillsSummary: string,
+ memoryDir: string = skillsDir.replace(/[/\\]skills$/, ''),
+ /**
+ * Snapshot of the current memory inbox state, formatted for the agent's
+ * initial context. Lets the agent see what's already pending so it can
+ * extend or rewrite existing canonical patches instead of accumulating
+ * many small ones across sessions. Empty string = nothing pending.
+ */
+ pendingInboxSummary: string = '',
): LocalAgentDefinition => ({
kind: 'local',
name: 'confucius',
displayName: 'Skill Extractor',
description:
- 'Extracts reusable skills from past conversation sessions and writes them as SKILL.md files.',
+ 'Extracts durable memories and reusable skills from past conversation sessions.',
inputConfig: {
inputSchema: {
type: 'object',
@@ -279,6 +413,8 @@ export const SkillExtractionAgent = (
modelConfig: {
model: PREVIEW_GEMINI_FLASH_MODEL,
},
+ memoryInboxAccess: true,
+ autoMemoryExtractionWriteAccess: true,
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
@@ -288,7 +424,6 @@ export const SkillExtractionAgent = (
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
- SHELL_TOOL_NAME,
],
},
get promptConfig() {
@@ -298,6 +433,23 @@ export const SkillExtractionAgent = (
contextParts.push(`# Existing Skills\n\n${existingSkillsSummary}`);
}
+ if (pendingInboxSummary && pendingInboxSummary.trim().length > 0) {
+ contextParts.push(
+ [
+ '# Pending Memory Inbox',
+ '',
+ 'The following `.patch` files already exist in the memory inbox',
+ 'awaiting user review. If your new findings overlap with one of',
+ 'these patches, REWRITE that patch (overwrite the same path) with',
+ 'the merged content rather than creating a new patch file. Use the',
+ 'canonical filename `extraction.patch` per kind for any new patch',
+ 'so the inbox stays consolidated.',
+ '',
+ pendingInboxSummary,
+ ].join('\n'),
+ );
+ }
+
contextParts.push(
[
'# Session Index',
@@ -326,8 +478,8 @@ export const SkillExtractionAgent = (
.replace(/\$\{(\w+)\}/g, '{$1}');
return {
- systemPrompt: buildSystemPrompt(skillsDir),
- query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest repeated workflows using read_file to verify recurrence from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
+ systemPrompt: buildSystemPrompt(skillsDir, memoryDir),
+ query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest durable memory or repeated workflows using read_file to verify from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. Only write memory if it would clearly help a future session. If recurrence, durability, or future reuse is unclear, create no artifact and explain why. If no skill is justified, create no skill and explain why.`,
};
},
runConfig: {
diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts
index 732dec1809..bfca8b81d6 100644
--- a/packages/core/src/agents/types.ts
+++ b/packages/core/src/agents/types.ts
@@ -229,6 +229,21 @@ export interface LocalAgentDefinition<
*/
workspaceDirectories?: string[];
+ /**
+ * Allows this agent to access the canonical auto-memory inbox patch files
+ * under `/.inbox/{private,global}/extraction.patch`.
+ * This is intentionally narrow so the main session cannot bypass review by
+ * writing arbitrary inbox patches.
+ */
+ memoryInboxAccess?: boolean;
+
+ /**
+ * Restricts write validation for this agent to extracted skill artifacts and
+ * canonical auto-memory inbox patch files. Used by the background
+ * auto-memory extractor so active memory files cannot be edited directly.
+ */
+ autoMemoryExtractionWriteAccess?: boolean;
+
/**
* Optional inline MCP servers for this agent.
*/
@@ -354,3 +369,16 @@ export interface RunConfig {
*/
maxTurns?: number;
}
+
+/**
+ * Summary of an agent reload operation.
+ */
+export interface AgentReloadSummary {
+ totalLoaded: number;
+ localCount: number;
+ remoteCount: number;
+ newAgents: string[];
+ updatedAgents: string[];
+ deletedAgents: string[];
+ errors: string[];
+}
diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts
index 84a777820a..b6b26f280a 100644
--- a/packages/core/src/code_assist/oauth2.test.ts
+++ b/packages/core/src/code_assist/oauth2.test.ts
@@ -1452,6 +1452,67 @@ describe('oauth2', () => {
stdinRemoveListenerSpy.mockRestore();
});
+ it('should NOT cancel when 0x03 is embedded in a multi-byte escape sequence (Ghostty/VS Code WSL false-positive)', async () => {
+ // Only a lone 0x03 byte is Ctrl+C; a multi-byte escape sequence that
+ // merely contains 0x03 (e.g. from Ghostty on init/resize) must not cancel.
+ const stdinOnSpy = vi
+ .spyOn(process.stdin, 'on')
+ .mockImplementation(() => process.stdin);
+ vi.spyOn(process.stdin, 'removeListener').mockImplementation(
+ () => process.stdin,
+ );
+
+ const mockHttpServer = {
+ listen: vi.fn(),
+ close: vi.fn(),
+ on: vi.fn(),
+ address: () => ({ port: 3000 }),
+ };
+ (http.createServer as Mock).mockImplementation(
+ () => mockHttpServer as unknown as http.Server,
+ );
+ vi.mocked(OAuth2Client).mockImplementation(
+ () =>
+ ({
+ generateAuthUrl: vi.fn().mockReturnValue('https://example.com'),
+ on: vi.fn(),
+ }) as unknown as OAuth2Client,
+ );
+ vi.mocked(open).mockImplementation(
+ async () => ({ on: vi.fn() }) as never,
+ );
+
+ const clientPromise = getOauthClient(
+ AuthType.LOGIN_WITH_GOOGLE,
+ mockConfig,
+ );
+
+ // Grab the registered stdin data handler
+ let dataHandler: ((data: Buffer) => void) | undefined;
+ await vi.waitFor(() => {
+ dataHandler = stdinOnSpy.mock.calls.find(
+ (c: [string | symbol, ...unknown[]]) => c[0] === 'data',
+ )?.[1] as (data: Buffer) => void;
+ if (!dataHandler) throw new Error('handler not registered');
+ });
+
+ // Fire an escape sequence embedding 0x03 ā must NOT cancel.
+ dataHandler!(Buffer.from([0x1b, 0x5b, 0x03, 0x4d])); // ESC [ 0x03 M
+
+ // Promise must still be pending (not rejected).
+ const result = await Promise.race([
+ clientPromise.then(
+ () => 'resolved',
+ () => 'rejected',
+ ),
+ new Promise((r) => setTimeout(() => r('pending'), 50)),
+ ]);
+ expect(result).toBe('pending');
+
+ stdinOnSpy.mockRestore();
+ vi.spyOn(process.stdin, 'removeListener').mockRestore();
+ });
+
it('should throw FatalCancellationError when consent is denied', async () => {
vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation(
(payload) => {
diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts
index 40be9c2236..8ea83e5270 100644
--- a/packages/core/src/code_assist/oauth2.ts
+++ b/packages/core/src/code_assist/oauth2.ts
@@ -356,8 +356,10 @@ async function initOauthClient(
// Note that SIGINT might not get raised on Ctrl+C in raw mode
// so we also need to look for Ctrl+C directly in stdin.
+ // Only match a lone 0x03 byte ā some terminals (e.g. Ghostty) embed
+ // 0x03 inside multi-byte escape sequences, causing false cancellations.
stdinHandler = (data: Buffer) => {
- if (data.includes(0x03)) {
+ if (data.length === 1 && data[0] === 0x03) {
reject(
new FatalCancellationError('Authentication cancelled by user.'),
);
diff --git a/packages/core/src/commands/memory.test.ts b/packages/core/src/commands/memory.test.ts
index 027bb2633f..00c8a2f324 100644
--- a/packages/core/src/commands/memory.test.ts
+++ b/packages/core/src/commands/memory.test.ts
@@ -12,9 +12,12 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
addMemory,
+ applyInboxMemoryPatch,
dismissInboxSkill,
+ dismissInboxMemoryPatch,
listInboxSkills,
listInboxPatches,
+ listInboxMemoryPatches,
applyInboxPatch,
dismissInboxPatch,
listMemoryFiles,
@@ -31,6 +34,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn(),
+ getGlobalGeminiDir: vi.fn(),
},
}));
@@ -315,6 +319,619 @@ describe('memory commands', () => {
});
});
+ describe('memory patch inbox', () => {
+ let tmpDir: string;
+ let memoryTempDir: string;
+ let projectRoot: string;
+ let globalMemoryDir: string;
+ let patchConfig: Config;
+
+ function buildUpdatePatch(
+ absoluteTargetPath: string,
+ original: string,
+ updated: string,
+ ): string {
+ // Minimal one-hunk patch that replaces `original` with `updated`.
+ const oldLines = original === '' ? 0 : original.split('\n').length - 1;
+ const newLines = updated === '' ? 0 : updated.split('\n').length - 1;
+ const removed = original
+ .split('\n')
+ .slice(0, oldLines)
+ .map((line) => `-${line}`);
+ const added = updated
+ .split('\n')
+ .slice(0, newLines)
+ .map((line) => `+${line}`);
+ return [
+ `--- ${absoluteTargetPath}`,
+ `+++ ${absoluteTargetPath}`,
+ `@@ -1,${oldLines} +1,${newLines} @@`,
+ ...removed,
+ ...added,
+ '',
+ ].join('\n');
+ }
+
+ function buildCreationPatch(
+ absoluteTargetPath: string,
+ content: string,
+ ): string {
+ const contentLines = content.split('\n');
+ const lineCount = content.endsWith('\n')
+ ? contentLines.length - 1
+ : contentLines.length;
+ const additions = (
+ content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines
+ ).map((line) => `+${line}`);
+ return [
+ `--- /dev/null`,
+ `+++ ${absoluteTargetPath}`,
+ `@@ -0,0 +1,${lineCount} @@`,
+ ...additions,
+ '',
+ ].join('\n');
+ }
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-'));
+ // Canonicalize so test-side paths match production's
+ // canonicalizeDirIfPresent ā fs.realpath. On Windows runners
+ // os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but
+ // fs.realpath expands it to the long form (C:\Users\runneradmin\...),
+ // which would otherwise break the auto-pointer absolute-path asserts.
+ tmpDir = await fs.realpath(tmpDir);
+ memoryTempDir = path.join(tmpDir, 'memory-temp');
+ projectRoot = path.join(tmpDir, 'project');
+ globalMemoryDir = path.join(tmpDir, 'global');
+ await fs.mkdir(memoryTempDir, { recursive: true });
+ await fs.mkdir(projectRoot, { recursive: true });
+ await fs.mkdir(globalMemoryDir, { recursive: true });
+
+ patchConfig = {
+ storage: {
+ getProjectMemoryTempDir: () => memoryTempDir,
+ getProjectMemoryDir: () => memoryTempDir,
+ },
+ isTrustedFolder: () => true,
+ } as unknown as Config;
+ vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir);
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ it('aggregates all .patch files of a kind into a single inbox entry', async () => {
+ // Multiple physical .patch files in the kind dir ā ONE consolidated
+ // inbox entry per kind, with all hunks merged into entries[].
+ const target = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(target, '- old\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'a-update.patch'),
+ buildUpdatePatch(target, '- old\n', '- new\n'),
+ );
+ // Second source patch ā same kind, different hunk.
+ const sibling = path.join(memoryTempDir, 'topic.md');
+ await fs.writeFile(sibling, 'topic A\n');
+ await fs.writeFile(
+ path.join(patchDir, 'b-topic.patch'),
+ buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
+ );
+
+ const patches = await listInboxMemoryPatches(patchConfig);
+
+ expect(patches).toHaveLength(1);
+ const memoryPatch = patches[0];
+ expect(memoryPatch).toMatchObject({
+ kind: 'private',
+ relativePath: 'private',
+ name: 'Private memory',
+ });
+ // Both source files contributed their hunks.
+ expect(memoryPatch.entries).toHaveLength(2);
+ expect(memoryPatch.sourceFiles).toEqual([
+ 'a-update.patch',
+ 'b-topic.patch',
+ ]);
+ expect(memoryPatch.entries[0].targetPath).toBe(target);
+ expect(memoryPatch.entries[0].isNewFile).toBe(false);
+ expect(memoryPatch.entries[1].targetPath).toBe(sibling);
+ expect(memoryPatch.extractedAt).toBeDefined();
+ });
+
+ it('omits patches whose headers leave the allowed root from the listing', async () => {
+ // Bad patches must NOT show up in the inbox at all ā listing filters
+ // them out so the user only ever sees actionable items. (They'd also
+ // be rejected at Apply time, but we don't want to surface them.)
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'escape.patch'),
+ buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'),
+ );
+
+ const patches = await listInboxMemoryPatches(patchConfig);
+ expect(patches).toHaveLength(0);
+
+ // Direct apply still rejects it (defense-in-depth).
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'escape.patch',
+ );
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/outside the private memory root/i);
+ });
+
+ it('omits global patches with disallowed targets from the listing', async () => {
+ // Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed.
+ // memory.md (legacy lowercase), sibling .md files, and settings.json all
+ // get filtered out of the listing instead of confusing the user.
+ const patchDir = path.join(memoryTempDir, '.inbox', 'global');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'wrong-name.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'memory.md'),
+ 'rejected\n',
+ ),
+ );
+ await fs.writeFile(
+ path.join(patchDir, 'sibling.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'notes.md'),
+ 'rejected\n',
+ ),
+ );
+ await fs.writeFile(
+ path.join(patchDir, 'settings.patch'),
+ buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'),
+ );
+
+ const patches = await listInboxMemoryPatches(patchConfig);
+ expect(patches).toHaveLength(0);
+ });
+
+ it('applies a private update patch and removes it from the inbox', async () => {
+ const target = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(target, '- old\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'MEMORY.patch'),
+ buildUpdatePatch(target, '- old\n', '- accepted\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'MEMORY.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n');
+ await expect(
+ fs.access(path.join(patchDir, 'MEMORY.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('applies a private creation patch with a paired MEMORY.md pointer', async () => {
+ // The auto-memory contract: creating a sibling .md file requires a
+ // hunk that adds a pointer to MEMORY.md (so the sibling becomes
+ // discoverable to future sessions).
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(memoryMd, '# Project Memory\n');
+
+ const target = path.join(memoryTempDir, 'topic.md');
+ await expect(fs.access(target)).rejects.toThrow();
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ const multiHunkPatch =
+ buildCreationPatch(target, '# Topic\n- new fact\n') +
+ buildUpdatePatch(
+ memoryMd,
+ '# Project Memory\n',
+ '# Project Memory\n- See topic.md for the new fact.\n',
+ );
+ await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch);
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'topic.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
+ '# Topic\n- new fact\n',
+ );
+ await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain(
+ 'See topic.md',
+ );
+ await expect(
+ fs.access(path.join(patchDir, 'topic.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => {
+ // Sibling .md files in are loaded by future sessions ONLY
+ // when MEMORY.md references them. To avoid orphans, applying a sibling
+ // creation patch with no MEMORY.md update auto-bundles a pointer line.
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(memoryMd, '# Project Memory\n');
+
+ const target = path.join(memoryTempDir, 'orphan-topic.md');
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'orphan-topic.patch'),
+ buildCreationPatch(target, '# Orphan Topic\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'orphan-topic.patch',
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
+ expect(result.message).toContain('"orphan-topic.md"');
+ // The sibling exists.
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
+ '# Orphan Topic\n',
+ );
+ // MEMORY.md now references the sibling ā using ABSOLUTE PATH so a
+ // future agent can `read_file` it without resolving relatives. We
+ // assert the line shape is `- See /orphan-topic.md ...` and
+ // verify the path is absolute via path.isAbsolute (cross-platform ā
+ // the previous /^- See \/.+\/.../ regex was Unix-only and broke on
+ // Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`).
+ const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
+ expect(memoryAfter).toContain(target);
+ const pointerLineMatch = memoryAfter.match(
+ /^- See (.+orphan-topic\.md) /m,
+ );
+ expect(pointerLineMatch).not.toBeNull();
+ expect(path.isAbsolute(pointerLineMatch![1])).toBe(true);
+ // The patch was committed and removed from inbox.
+ await expect(
+ fs.access(path.join(patchDir, 'orphan-topic.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => {
+ // No MEMORY.md on disk + a creation patch for a sibling ā
+ // auto-bundle should create MEMORY.md from scratch with the pointer.
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await expect(fs.access(memoryMd)).rejects.toThrow();
+
+ const target = path.join(memoryTempDir, 'fresh-topic.md');
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'fresh-topic.patch'),
+ buildCreationPatch(target, '# Fresh Topic\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'fresh-topic.patch',
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
+ const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
+ expect(memoryAfter).toContain('Project Memory');
+ // Pointer must be absolute so the future agent can read_file directly.
+ expect(memoryAfter).toContain(target);
+ });
+
+ it('accepts a private creation patch when MEMORY.md already references the new file', async () => {
+ // If MEMORY.md was previously prepared with a pointer (e.g. by a
+ // separately-applied patch), the follow-up creation patch is fine.
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(
+ memoryMd,
+ '# Project Memory\n- See later-topic.md for details.\n',
+ );
+
+ const target = path.join(memoryTempDir, 'later-topic.md');
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'later-topic.patch'),
+ buildCreationPatch(target, '# Later Topic\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'later-topic.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
+ '# Later Topic\n',
+ );
+ });
+
+ it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => {
+ const target = path.join(globalMemoryDir, 'GEMINI.md');
+ // Sanity check: target does not exist before apply.
+ await expect(fs.access(target)).rejects.toThrow();
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'global');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'GEMINI.patch'),
+ buildCreationPatch(target, '# Personal preferences\n- prefer X\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'global',
+ 'GEMINI.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
+ '# Personal preferences\n- prefer X\n',
+ );
+ await expect(
+ fs.access(path.join(patchDir, 'GEMINI.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('applies a global update patch to ~/.gemini/GEMINI.md', async () => {
+ const target = path.join(globalMemoryDir, 'GEMINI.md');
+ await fs.writeFile(target, '- prefer X\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'global');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'GEMINI.patch'),
+ buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'global',
+ 'GEMINI.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n');
+ await expect(
+ fs.access(path.join(patchDir, 'GEMINI.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => {
+ const patchDir = path.join(memoryTempDir, '.inbox', 'global');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'GEMINI.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'GEMINI.md'),
+ 'Prefer concise.\n',
+ ),
+ );
+
+ const result = await dismissInboxMemoryPatch(
+ patchConfig,
+ 'global',
+ 'GEMINI.patch',
+ );
+
+ expect(result.success).toBe(true);
+ await expect(
+ fs.access(path.join(patchDir, 'GEMINI.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('apply with relativePath = kind runs every source patch in sequence', async () => {
+ // Aggregate apply: pass `relativePath = kind`. Each .patch file under
+ // the kind dir is applied atomically in lexical order; the result
+ // message summarizes successes/failures.
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(memoryMd, '- old\n');
+ const sibling = path.join(memoryTempDir, 'topic.md');
+ await fs.writeFile(sibling, 'topic A\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'a-update.patch'),
+ buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
+ );
+ await fs.writeFile(
+ path.join(patchDir, 'b-topic.patch'),
+ buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'private', // ā aggregate mode
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.message).toMatch(/applied all 2 private memory patches/i);
+
+ // Both targets were updated, both source patches removed.
+ await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
+ await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n');
+ await expect(
+ fs.access(path.join(patchDir, 'a-update.patch')),
+ ).rejects.toThrow();
+ await expect(
+ fs.access(path.join(patchDir, 'b-topic.patch')),
+ ).rejects.toThrow();
+ });
+
+ it('aggregate apply reports successes and failures when one source patch is stale', async () => {
+ const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(memoryMd, '- old\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ // Good patch: updates the existing line.
+ await fs.writeFile(
+ path.join(patchDir, 'a-good.patch'),
+ buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
+ );
+ // Stale patch: context expects something that doesn't exist.
+ await fs.writeFile(
+ path.join(patchDir, 'b-stale.patch'),
+ buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'private',
+ );
+
+ // Any failure ā success=false so the dialog keeps the inbox entry
+ // visible. (The successful sub-patches were already removed from disk;
+ // the next listing will surface only the failures for retry.)
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/applied 1 of 2/i);
+ expect(result.message).toMatch(/b-stale\.patch/);
+
+ // Good patch committed and removed; stale patch stays in inbox.
+ await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
+ await expect(
+ fs.access(path.join(patchDir, 'a-good.patch')),
+ ).rejects.toThrow();
+ await expect(
+ fs.access(path.join(patchDir, 'b-stale.patch')),
+ ).resolves.toBeUndefined();
+ });
+
+ it('dismiss with relativePath = kind removes all source patches', async () => {
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'a.patch'),
+ buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'),
+ );
+ await fs.writeFile(
+ path.join(patchDir, 'b.patch'),
+ buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'),
+ );
+
+ const result = await dismissInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'private',
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.message).toMatch(/dismissed 2/i);
+ await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow();
+ await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow();
+ });
+
+ it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => {
+ const patchDir = path.join(memoryTempDir, '.inbox', 'global');
+ await fs.mkdir(patchDir, { recursive: true });
+
+ // memory.md (lowercase) is NOT a valid global memory file.
+ await fs.writeFile(
+ path.join(patchDir, 'wrong-name.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'memory.md'),
+ 'Should be rejected.\n',
+ ),
+ );
+
+ // Sibling .md files in ~/.gemini/ are also not allowed.
+ await fs.writeFile(
+ path.join(patchDir, 'sibling.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'notes.md'),
+ 'Should be rejected.\n',
+ ),
+ );
+
+ // Non-memory files (settings, credentials) must stay off-limits.
+ await fs.writeFile(
+ path.join(patchDir, 'settings.patch'),
+ buildCreationPatch(
+ path.join(globalMemoryDir, 'settings.json'),
+ '{"foo": 1}\n',
+ ),
+ );
+
+ for (const fileName of [
+ 'wrong-name.patch',
+ 'sibling.patch',
+ 'settings.patch',
+ ]) {
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'global',
+ fileName,
+ );
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/outside the global memory root/i);
+ }
+
+ // None of the bogus targets were created.
+ for (const orphan of ['memory.md', 'notes.md', 'settings.json']) {
+ await expect(
+ fs.access(path.join(globalMemoryDir, orphan)),
+ ).rejects.toThrow();
+ }
+ });
+
+ it('rejects invalid memory patch paths', async () => {
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ '../MEMORY.patch',
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.message).toBe('Invalid memory patch path.');
+ });
+
+ it('rejects a creation patch whose target already exists', async () => {
+ const target = path.join(memoryTempDir, 'MEMORY.md');
+ await fs.writeFile(target, 'pre-existing\n');
+
+ const patchDir = path.join(memoryTempDir, '.inbox', 'private');
+ await fs.mkdir(patchDir, { recursive: true });
+ await fs.writeFile(
+ path.join(patchDir, 'MEMORY.patch'),
+ buildCreationPatch(target, 'replacement\n'),
+ );
+
+ const result = await applyInboxMemoryPatch(
+ patchConfig,
+ 'private',
+ 'MEMORY.patch',
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/declares a new file/);
+ await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
+ 'pre-existing\n',
+ );
+ await expect(
+ fs.access(path.join(patchDir, 'MEMORY.patch')),
+ ).resolves.toBeUndefined();
+ });
+ });
+
describe('moveInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
diff --git a/packages/core/src/commands/memory.ts b/packages/core/src/commands/memory.ts
index 286cbe0e3e..53f9564871 100644
--- a/packages/core/src/commands/memory.ts
+++ b/packages/core/src/commands/memory.ts
@@ -13,11 +13,15 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
+import { getGlobalMemoryFilePath } from '../tools/memoryTool.js';
import {
type AppliedSkillPatchTarget,
+ applyParsedPatchesWithAllowedRoots,
applyParsedSkillPatches,
+ canonicalizeAllowedPatchRoots,
hasParsedPatchHunks,
isProjectSkillPatchTarget,
+ resolveTargetWithinAllowedRoots,
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
@@ -338,6 +342,46 @@ export interface InboxPatch {
extractedAt?: string;
}
+export type InboxMemoryPatchKind = 'private' | 'global';
+
+/**
+ * One target file inside a memory patch (most patches will have a single entry).
+ */
+export interface InboxMemoryPatchEntry {
+ /** Absolute path of the markdown file the patch will modify. */
+ targetPath: string;
+ /** Unified diff for this single file (used for UI preview). */
+ diffContent: string;
+ /** True when this entry creates a new file (`/dev/null` source). */
+ isNewFile: boolean;
+}
+
+/**
+ * Represents the AGGREGATED inbox state for one memory kind. Even when the
+ * extraction agent has produced multiple `.patch` files under
+ * `/.inbox//` (e.g. across several sessions), the inbox
+ * surfaces them as ONE entry per kind. Apply runs each underlying patch in
+ * sequence; Dismiss removes them all.
+ */
+export interface InboxMemoryPatch {
+ /** Memory tier ā one entry per kind in the inbox. */
+ kind: InboxMemoryPatchKind;
+ /**
+ * Stable identifier for this consolidated entry. Set to the kind itself
+ * (`"private"` or `"global"`); kept in the type for backwards-compat with
+ * the per-file API the dialog passes through.
+ */
+ relativePath: string;
+ /** Display name shown in the inbox row (e.g. `"Private memory"`). */
+ name: string;
+ /** All hunks from all underlying source patches, concatenated in order. */
+ entries: InboxMemoryPatchEntry[];
+ /** Basenames of the underlying `.patch` files being aggregated. */
+ sourceFiles: string[];
+ /** Most recent mtime across the source files (ISO string), if known. */
+ extractedAt?: string;
+}
+
interface StagedInboxPatchTarget {
targetPath: string;
tempPath: string;
@@ -372,6 +416,97 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+function getMemoryPatchRoot(
+ memoryDir: string,
+ kind: InboxMemoryPatchKind,
+): string {
+ return path.join(memoryDir, '.inbox', kind);
+}
+
+function isSubpathOrSame(childPath: string, parentPath: string): boolean {
+ const relativePath = path.relative(parentPath, childPath);
+ return (
+ relativePath === '' ||
+ (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
+ );
+}
+
+function normalizeInboxMemoryPatchPath(
+ relativePath: string,
+): string | undefined {
+ if (
+ relativePath.length === 0 ||
+ path.isAbsolute(relativePath) ||
+ relativePath.includes('\\')
+ ) {
+ return undefined;
+ }
+
+ const normalizedPath = path.posix.normalize(relativePath);
+ if (
+ normalizedPath === '.' ||
+ normalizedPath.startsWith('../') ||
+ normalizedPath === '..' ||
+ !normalizedPath.endsWith('.patch')
+ ) {
+ return undefined;
+ }
+ return normalizedPath;
+}
+
+/**
+ * Returns the directory roots (or single-file allowlists) that a memory patch
+ * of the given kind is allowed to modify. Memory patch headers must reference
+ * paths inside / equal to one of these entries after canonical resolution.
+ *
+ * - `private` allows any markdown file inside the project memory directory.
+ * - `global` is intentionally a single-file allowlist: the only writeable
+ * global file is the personal `~/.gemini/GEMINI.md`. Other files under
+ * `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits.
+ */
+export function getAllowedMemoryPatchRoots(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+): string[] {
+ switch (kind) {
+ case 'private':
+ return [path.resolve(config.storage.getProjectMemoryTempDir())];
+ case 'global':
+ return [path.resolve(getGlobalMemoryFilePath())];
+ default:
+ throw new Error(`Unknown memory patch kind: ${kind as string}`);
+ }
+}
+
+async function getFileMtimeIso(filePath: string): Promise {
+ try {
+ const stats = await fs.stat(filePath);
+ return stats.mtime.toISOString();
+ } catch {
+ return undefined;
+ }
+}
+
+async function getInboxMemoryPatchSourcePath(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+ relativePath: string,
+): Promise {
+ const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
+ if (!normalizedPath) {
+ return undefined;
+ }
+
+ const patchRoot = path.resolve(
+ getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind),
+ );
+ const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/'));
+ if (!isSubpathOrSame(sourcePath, patchRoot)) {
+ return undefined;
+ }
+ return sourcePath;
+}
+
async function patchTargetsProjectSkills(
targetPaths: string[],
config: Config,
@@ -395,6 +530,670 @@ async function getPatchExtractedAt(
}
}
+function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
+ switch (kind) {
+ case 'private':
+ return 'Private memory';
+ case 'global':
+ return 'Global memory';
+ default:
+ return kind;
+ }
+}
+
+/**
+ * Returns the absolute paths of every `.patch` file currently in the kind's
+ * inbox directory (sorted by basename for stable ordering at apply time).
+ *
+ * NOTE: this is a raw filesystem listing ā it does NOT validate patch shape
+ * or that targets fall inside the kind's allowed root. Callers that need
+ * "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`.
+ */
+async function listInboxPatchFiles(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+): Promise {
+ const patchRoot = getMemoryPatchRoot(
+ config.storage.getProjectMemoryTempDir(),
+ kind,
+ );
+ const found: string[] = [];
+
+ async function walk(currentDir: string): Promise {
+ let dirEntries: Array;
+ try {
+ dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ for (const entry of dirEntries) {
+ const entryPath = path.join(currentDir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(entryPath);
+ continue;
+ }
+ if (entry.isFile() && entry.name.endsWith('.patch')) {
+ found.push(entryPath);
+ }
+ }
+ }
+
+ await walk(patchRoot);
+ return found.sort();
+}
+
+/**
+ * Returns only the inbox patch files that pass the same validation as the
+ * inbox listing (parseable, has hunks, valid headers, targets in the
+ * kind's allowed root). Used by aggregate apply so the user only ever sees
+ * results for patches the inbox actually surfaced.
+ */
+async function listValidInboxPatchFiles(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+): Promise {
+ const patchFiles = await listInboxPatchFiles(config, kind);
+ if (patchFiles.length === 0) {
+ return [];
+ }
+
+ const allowedRoots = await canonicalizeAllowedPatchRoots(
+ getAllowedMemoryPatchRoots(config, kind),
+ );
+
+ const valid: string[] = [];
+ for (const sourcePath of patchFiles) {
+ let content: string;
+ try {
+ content = await fs.readFile(sourcePath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ let parsed: Diff.StructuredPatch[];
+ try {
+ parsed = Diff.parsePatch(content);
+ } catch {
+ continue;
+ }
+ if (!hasParsedPatchHunks(parsed)) {
+ continue;
+ }
+
+ const validated = validateParsedSkillPatchHeaders(parsed);
+ if (!validated.success) {
+ continue;
+ }
+
+ const targetsAllAllowed = await Promise.all(
+ validated.patches.map(
+ async (header) =>
+ (await resolveTargetWithinAllowedRoots(
+ header.targetPath,
+ allowedRoots,
+ )) !== undefined,
+ ),
+ );
+ if (!targetsAllAllowed.every(Boolean)) {
+ continue;
+ }
+
+ valid.push(sourcePath);
+ }
+ return valid;
+}
+
+/**
+ * Scans `/.inbox/{private,global}/` and returns ONE consolidated
+ * inbox entry per kind. Each entry aggregates all hunks from every valid
+ * underlying `.patch` file. Patches that fail validation (unparseable, no
+ * hunks, target outside allowed root) are silently skipped so they don't
+ * pollute the inbox UI.
+ */
+export async function listInboxMemoryPatches(
+ config: Config,
+): Promise {
+ const kinds: InboxMemoryPatchKind[] = ['private', 'global'];
+ const aggregated: InboxMemoryPatch[] = [];
+
+ for (const kind of kinds) {
+ const allowedRoots = await canonicalizeAllowedPatchRoots(
+ getAllowedMemoryPatchRoots(config, kind),
+ );
+ const patchFiles = await listInboxPatchFiles(config, kind);
+
+ const aggregatedEntries: InboxMemoryPatchEntry[] = [];
+ const sourceFiles: string[] = [];
+ let latestMtime: string | undefined;
+
+ for (const sourcePath of patchFiles) {
+ let content: string;
+ try {
+ content = await fs.readFile(sourcePath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ let parsed: Diff.StructuredPatch[];
+ try {
+ parsed = Diff.parsePatch(content);
+ } catch {
+ continue;
+ }
+ if (!hasParsedPatchHunks(parsed)) {
+ continue;
+ }
+
+ const validated = validateParsedSkillPatchHeaders(parsed);
+ if (!validated.success) {
+ continue;
+ }
+
+ // Skip the entire source file if ANY of its targets escapes the kind's
+ // allowed root.
+ const targetsAllAllowed = await Promise.all(
+ validated.patches.map(
+ async (header) =>
+ (await resolveTargetWithinAllowedRoots(
+ header.targetPath,
+ allowedRoots,
+ )) !== undefined,
+ ),
+ );
+ if (!targetsAllAllowed.every(Boolean)) {
+ continue;
+ }
+
+ for (const [index, header] of validated.patches.entries()) {
+ aggregatedEntries.push({
+ targetPath: header.targetPath,
+ isNewFile: header.isNewFile,
+ diffContent: formatParsedDiff(parsed[index]),
+ });
+ }
+
+ sourceFiles.push(path.basename(sourcePath));
+
+ const mtime = await getFileMtimeIso(sourcePath);
+ if (mtime && (!latestMtime || mtime > latestMtime)) {
+ latestMtime = mtime;
+ }
+ }
+
+ if (aggregatedEntries.length === 0) {
+ continue;
+ }
+
+ aggregated.push({
+ kind,
+ relativePath: kind,
+ name: formatMemoryKindLabel(kind),
+ entries: aggregatedEntries,
+ sourceFiles,
+ extractedAt: latestMtime,
+ });
+ }
+
+ return aggregated;
+}
+
+/**
+ * Applies an inbox memory patch atomically and removes the patch on success.
+ *
+ * Process:
+ * 1. Parse + validate the patch headers (absolute paths only, no `a/`/`b/`).
+ * 2. Dry-run the patch against the current target content (or empty for
+ * `/dev/null` creation patches).
+ * 3. Stage the patched content to a temp file, then rename into place.
+ * 4. On any failure, restore previous content from the staged snapshot and
+ * leave the inbox patch intact for retry.
+ */
+/**
+ * Applies one inbox memory entry. Two modes:
+ * - Aggregate mode (`relativePath === kind`): walk every `.patch` file in
+ * the kind's inbox directory and apply each one in lexical order. Each
+ * file is its own atomic transaction; failures don't block subsequent
+ * successes. Returns an aggregated summary (e.g. "Applied 3 of 4 sub-
+ * patches; 1 failed: ā¦").
+ * - Single-file mode (legacy): `relativePath` points at a specific
+ * `.patch` filename. Used by tests and direct callers.
+ */
+export async function applyInboxMemoryPatch(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+ relativePath: string,
+): Promise<{ success: boolean; message: string }> {
+ if (relativePath === kind) {
+ return applyAllInboxPatchesForKind(config, kind);
+ }
+
+ const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
+ if (!normalizedPath) {
+ return { success: false, message: 'Invalid memory patch path.' };
+ }
+
+ const sourcePath = await getInboxMemoryPatchSourcePath(
+ config,
+ kind,
+ normalizedPath,
+ );
+ if (!sourcePath) {
+ return { success: false, message: 'Invalid memory patch path.' };
+ }
+
+ return applyMemoryPatchFile(config, kind, sourcePath, normalizedPath);
+}
+
+async function applyAllInboxPatchesForKind(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+): Promise<{ success: boolean; message: string }> {
+ // Only attempt patches the user actually saw in the inbox listing.
+ // Files that were filtered (bad headers, escape allowed root, etc.) stay
+ // on disk untouched.
+ const patchFiles = await listValidInboxPatchFiles(config, kind);
+ if (patchFiles.length === 0) {
+ return {
+ success: false,
+ message: `No ${kind} memory patches in inbox.`,
+ };
+ }
+
+ const successes: string[] = [];
+ const failures: Array<{ name: string; reason: string }> = [];
+ let pointersAddedAcrossPatches: string[] = [];
+
+ for (const sourcePath of patchFiles) {
+ const basename = path.basename(sourcePath);
+ const result = await applyMemoryPatchFile(
+ config,
+ kind,
+ sourcePath,
+ basename,
+ );
+ if (result.success) {
+ successes.push(basename);
+ // Surface auto-added MEMORY.md pointer info if present.
+ const pointerMatch = result.message.match(
+ /Auto-added MEMORY\.md pointer for ([^.]+)\./,
+ );
+ if (pointerMatch) {
+ pointersAddedAcrossPatches.push(pointerMatch[1]);
+ }
+ } else {
+ failures.push({ name: basename, reason: result.message });
+ }
+ }
+
+ // De-dup pointer notes (same sibling could have been mentioned twice).
+ pointersAddedAcrossPatches = Array.from(new Set(pointersAddedAcrossPatches));
+
+ const total = successes.length + failures.length;
+ if (failures.length === 0) {
+ const pointerNote =
+ pointersAddedAcrossPatches.length > 0
+ ? ` Auto-added MEMORY.md pointer(s) for ${pointersAddedAcrossPatches.join('; ')}.`
+ : '';
+ return {
+ success: true,
+ message: `Applied all ${successes.length} ${kind} memory patch${
+ successes.length === 1 ? '' : 'es'
+ }.${pointerNote}`,
+ };
+ }
+
+ const failureSummary = failures
+ .map((f) => `"${f.name}" ā ${f.reason}`)
+ .join('; ');
+ // Any failure ā success=false so the dialog keeps the inbox entry visible
+ // (the user needs to see and retry/dismiss the remaining sub-patches).
+ // The successful sub-patches have already been removed from disk by
+ // applyMemoryPatchFile, so the next listing will show only the failures.
+ return {
+ success: false,
+ message:
+ `Applied ${successes.length} of ${total} ${kind} memory patches. ` +
+ `${failures.length} failed: ${failureSummary}`,
+ };
+}
+
+async function canonicalizeDirIfPresent(dirPath: string): Promise {
+ try {
+ return await fs.realpath(dirPath);
+ } catch {
+ return path.resolve(dirPath);
+ }
+}
+
+/**
+ * Returns the basenames of any sibling .md files (not MEMORY.md itself) that
+ * are being CREATED by this patch under `/` directly.
+ */
+function findSiblingCreations(
+ appliedResults: readonly AppliedSkillPatchTarget[],
+ memoryDir: string,
+): AppliedSkillPatchTarget[] {
+ return appliedResults.filter((entry) => {
+ if (!entry.isNewFile) return false;
+ const targetDir = path.dirname(path.resolve(entry.targetPath));
+ if (targetDir !== memoryDir) return false;
+ const basename = path.basename(entry.targetPath);
+ if (basename.toLowerCase() === 'memory.md') return false;
+ return basename.toLowerCase().endsWith('.md');
+ });
+}
+
+interface AutoPointerAugmentation {
+ /** Patch results, possibly with a synthesized/extended MEMORY.md entry. */
+ results: AppliedSkillPatchTarget[];
+ /** Sibling basenames a pointer was auto-added for (empty if none). */
+ pointersAdded: string[];
+}
+
+/**
+ * MEMORY.md is the index that gets injected into future agent contexts.
+ * Sibling .md files in `/` are loaded ON DEMAND by the runtime
+ * agent via `read_file` ā but only IF MEMORY.md references them by name
+ * (see `getUserProjectMemoryPaths`).
+ *
+ * If a private patch creates a sibling without also referencing it from
+ * MEMORY.md, the new file would never be discoverable. Rather than rejecting
+ * the patch (bad UX), we auto-bundle a MEMORY.md update that adds a
+ * one-line pointer per orphan sibling. The augmented entry is then committed
+ * atomically alongside the rest of the patch.
+ *
+ * If the patch already updates/creates MEMORY.md and the new content already
+ * references the sibling, no augmentation is needed.
+ */
+async function augmentWithAutoPointers(
+ config: Config,
+ appliedResults: readonly AppliedSkillPatchTarget[],
+): Promise {
+ const memoryDir = await canonicalizeDirIfPresent(
+ config.storage.getProjectMemoryTempDir(),
+ );
+ const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
+
+ const siblingCreations = findSiblingCreations(appliedResults, memoryDir);
+ if (siblingCreations.length === 0) {
+ return { results: [...appliedResults], pointersAdded: [] };
+ }
+
+ // Locate (or initialize) the MEMORY.md entry we'll mutate.
+ const existingIdx = appliedResults.findIndex(
+ (entry) => path.resolve(entry.targetPath) === memoryMdPath,
+ );
+ let memoryEntry: AppliedSkillPatchTarget;
+ if (existingIdx >= 0) {
+ memoryEntry = { ...appliedResults[existingIdx] };
+ } else {
+ let originalContent = '';
+ let isNewFile = true;
+ try {
+ originalContent = await fs.readFile(memoryMdPath, 'utf-8');
+ isNewFile = false;
+ } catch {
+ // MEMORY.md doesn't exist yet ā we'll create it with a default heading.
+ }
+ memoryEntry = {
+ targetPath: memoryMdPath,
+ original: originalContent,
+ patched: isNewFile ? '# Project Memory\n' : originalContent,
+ isNewFile,
+ };
+ }
+
+ const pointersAdded: string[] = [];
+ for (const sibling of siblingCreations) {
+ const basename = path.basename(sibling.targetPath);
+ // Resolve to absolute path so the runtime agent can `read_file` the
+ // sibling directly without needing to know .
+ const absoluteTarget = path.resolve(sibling.targetPath);
+ // Existing reference can be by either basename or absolute path; both count.
+ if (
+ memoryEntry.patched.includes(basename) ||
+ memoryEntry.patched.includes(absoluteTarget)
+ ) {
+ continue; // Already referenced.
+ }
+ const stem = basename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').trim();
+ const pointer = `- See ${absoluteTarget} for ${stem || basename} notes.`;
+ memoryEntry.patched = memoryEntry.patched.endsWith('\n')
+ ? `${memoryEntry.patched}${pointer}\n`
+ : `${memoryEntry.patched}\n${pointer}\n`;
+ pointersAdded.push(basename);
+ }
+
+ if (pointersAdded.length === 0) {
+ return { results: [...appliedResults], pointersAdded: [] };
+ }
+
+ const results = [...appliedResults];
+ if (existingIdx >= 0) {
+ results[existingIdx] = memoryEntry;
+ } else {
+ results.push(memoryEntry);
+ }
+ return { results, pointersAdded };
+}
+
+/**
+ * Internal helper: parses, validates, and atomically commits a memory patch
+ * file at a known absolute path. Separated from `applyInboxMemoryPatch` so the
+ * path-resolution and patch-apply concerns stay testable independently.
+ */
+async function applyMemoryPatchFile(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+ patchPath: string,
+ displayName: string,
+): Promise<{ success: boolean; message: string }> {
+ let content: string;
+ try {
+ content = await fs.readFile(patchPath, 'utf-8');
+ } catch {
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" not found in inbox.`,
+ };
+ }
+
+ let parsed: Diff.StructuredPatch[];
+ try {
+ parsed = Diff.parsePatch(content);
+ } catch (error) {
+ return {
+ success: false,
+ message: `Failed to parse memory patch "${displayName}": ${getErrorMessage(error)}`,
+ };
+ }
+ if (!hasParsedPatchHunks(parsed)) {
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" contains no valid hunks.`,
+ };
+ }
+
+ const allowedRoots = await canonicalizeAllowedPatchRoots(
+ getAllowedMemoryPatchRoots(config, kind),
+ );
+ const applied = await applyParsedPatchesWithAllowedRoots(
+ parsed,
+ allowedRoots,
+ );
+ if (!applied.success) {
+ switch (applied.reason) {
+ case 'missingTargetPath':
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" is missing a target file path.`,
+ };
+ case 'invalidPatchHeaders':
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" has invalid diff headers.`,
+ };
+ case 'outsideAllowedRoots':
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root: ${applied.targetPath}`,
+ };
+ case 'newFileAlreadyExists':
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" declares a new file, but the target already exists: ${applied.targetPath}`,
+ };
+ case 'targetNotFound':
+ return {
+ success: false,
+ message: `Target file not found: ${applied.targetPath}`,
+ };
+ case 'doesNotApply':
+ return {
+ success: false,
+ message: applied.isNewFile
+ ? `Memory patch "${displayName}" failed to apply for new file ${applied.targetPath}.`
+ : `Memory patch does not apply cleanly to ${applied.targetPath}.`,
+ };
+ default:
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" could not be applied.`,
+ };
+ }
+ }
+
+ // Auto-bundle a MEMORY.md pointer for any sibling .md the patch creates
+ // without referencing it from MEMORY.md. Without that pointer the new file
+ // would never be loaded into a future session (see augmentWithAutoPointers).
+ let pointersAdded: string[] = [];
+ let resultsToCommit: AppliedSkillPatchTarget[] = [...applied.results];
+ if (kind === 'private') {
+ const augmented = await augmentWithAutoPointers(config, applied.results);
+ resultsToCommit = augmented.results;
+ pointersAdded = augmented.pointersAdded;
+ }
+
+ let stagedTargets: StagedInboxPatchTarget[];
+ try {
+ stagedTargets = await stageInboxPatchTargets(resultsToCommit);
+ } catch (error) {
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" could not be staged: ${getErrorMessage(error)}.`,
+ };
+ }
+
+ const committedTargets: StagedInboxPatchTarget[] = [];
+ try {
+ for (const stagedTarget of stagedTargets) {
+ await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
+ committedTargets.push(stagedTarget);
+ }
+ } catch (error) {
+ for (const committedTarget of committedTargets.reverse()) {
+ try {
+ await restoreCommittedInboxPatchTarget(committedTarget);
+ } catch {
+ // Best-effort rollback. We still report the commit failure below.
+ }
+ }
+ await cleanupStagedInboxPatchTargets(
+ stagedTargets.filter((target) => !committedTargets.includes(target)),
+ );
+ return {
+ success: false,
+ message: `Memory patch "${displayName}" could not be applied atomically: ${getErrorMessage(error)}.`,
+ };
+ }
+
+ await fs.unlink(patchPath);
+
+ const fileCount = resultsToCommit.length;
+ const baseMessage = `Applied memory patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`;
+ const pointerNote =
+ pointersAdded.length > 0
+ ? ` Auto-added MEMORY.md pointer for ${pointersAdded
+ .map((name) => `"${name}"`)
+ .join(', ')} so the new sibling file is discoverable.`
+ : '';
+ return {
+ success: true,
+ message: `${baseMessage}${pointerNote}`,
+ };
+}
+
+/**
+ * Removes inbox memory patch(es) without applying. Two modes:
+ * - Aggregate (`relativePath === kind`): unlink every `.patch` file in the
+ * kind's inbox directory. Used by the consolidated inbox UI's Dismiss.
+ * - Single-file (legacy): unlink one specific `.patch` file.
+ */
+export async function dismissInboxMemoryPatch(
+ config: Config,
+ kind: InboxMemoryPatchKind,
+ relativePath: string,
+): Promise<{ success: boolean; message: string }> {
+ if (relativePath === kind) {
+ // Dismiss the same set of files the listing surfaced ā leave the
+ // already-filtered (bad-target, malformed) files alone for forensic
+ // inspection.
+ const patchFiles = await listValidInboxPatchFiles(config, kind);
+ if (patchFiles.length === 0) {
+ return {
+ success: false,
+ message: `No ${kind} memory patches in inbox.`,
+ };
+ }
+ let removed = 0;
+ for (const sourcePath of patchFiles) {
+ try {
+ await fs.unlink(sourcePath);
+ removed += 1;
+ } catch {
+ // Best-effort: keep going if one delete fails.
+ }
+ }
+ return {
+ success: removed > 0,
+ message: `Dismissed ${removed} ${kind} memory patch${
+ removed === 1 ? '' : 'es'
+ } from inbox.`,
+ };
+ }
+
+ const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
+ if (!normalizedPath) {
+ return { success: false, message: 'Invalid memory patch path.' };
+ }
+
+ const sourcePath = await getInboxMemoryPatchSourcePath(
+ config,
+ kind,
+ normalizedPath,
+ );
+ if (!sourcePath) {
+ return { success: false, message: 'Invalid memory patch path.' };
+ }
+
+ try {
+ await fs.access(sourcePath);
+ } catch {
+ return {
+ success: false,
+ message: `Memory patch "${normalizedPath}" not found in inbox.`,
+ };
+ }
+
+ await fs.unlink(sourcePath);
+
+ return {
+ success: true,
+ message: `Dismissed "${normalizedPath}" from inbox.`,
+ };
+}
+
async function findNearestExistingDirectory(
startPath: string,
): Promise {
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 55a3baf8ee..efff35eda7 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -23,6 +23,7 @@ import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
+import { coreEvents } from '../utils/events.js';
import { ApprovalMode } from '../policy/types.js';
import {
HookType,
@@ -71,6 +72,10 @@ import {
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
+import {
+ runWithScopedAutoMemoryExtractionWriteAccess,
+ runWithScopedMemoryInboxAccess,
+} from './scoped-config.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal();
@@ -202,6 +207,7 @@ const mockCoreEvents = vi.hoisted(() => ({
emitConsoleLog: vi.fn(),
emitQuotaChanged: vi.fn(),
on: vi.fn(),
+ emit: vi.fn(),
}));
const mockSetGlobalProxy = vi.hoisted(() => vi.fn());
@@ -1437,31 +1443,6 @@ describe('Server Config (config.ts)', () => {
});
});
- describe('ContinueOnFailedApiCall Configuration', () => {
- it('should default continueOnFailedApiCall to false when not provided', () => {
- const config = new Config(baseParams);
- expect(config.getContinueOnFailedApiCall()).toBe(true);
- });
-
- it('should set continueOnFailedApiCall to true when provided as true', () => {
- const paramsWithContinueOnFailedApiCall: ConfigParameters = {
- ...baseParams,
- continueOnFailedApiCall: true,
- };
- const config = new Config(paramsWithContinueOnFailedApiCall);
- expect(config.getContinueOnFailedApiCall()).toBe(true);
- });
-
- it('should set continueOnFailedApiCall to false when explicitly provided as false', () => {
- const paramsWithContinueOnFailedApiCall: ConfigParameters = {
- ...baseParams,
- continueOnFailedApiCall: false,
- };
- const config = new Config(paramsWithContinueOnFailedApiCall);
- expect(config.getContinueOnFailedApiCall()).toBe(false);
- });
- });
-
describe('createToolRegistry', () => {
it('should register a tool if coreTools contains an argument-specific pattern', async () => {
const params: ConfigParameters = {
@@ -1965,6 +1946,70 @@ describe('Server Config (config.ts)', () => {
expect(config.getSessionId()).toBe('session-two');
expect(config.getApprovedPlanPath()).toBeUndefined();
});
+
+ it('performs a comprehensive reset of all session-scoped state when sessionId changes', async () => {
+ const config = new Config({
+ ...baseParams,
+ sessionId: 'session-one',
+ plan: true,
+ tracker: true,
+ });
+
+ await config.initialize();
+
+ // 1. "Dirty" the session state
+ const oldTrackerService = config.getTrackerService();
+ config.setApprovedPlanPath('/tmp/plan.md');
+ config.topicState.setTopic('Old Topic', 'Old Intent');
+ config.getSkillManager().activateSkill('old-skill');
+ config.getModelAvailabilityService().markTerminal('model-1', 'quota');
+ config.setLatestApiRequest({} as never);
+
+ // Interface to access private fields without 'any'
+ interface PrivateConfig {
+ modelQuotas: Map;
+ lastEmittedQuotaRemaining: number | undefined;
+ lastEmittedQuotaLimit: number | undefined;
+ lastQuotaFetchTime: number;
+ hasAccessToPreviewModel: boolean | null;
+ }
+ const configInternal = config as unknown as PrivateConfig;
+
+ // Mock internal quota state
+ configInternal.modelQuotas.set('model-1', { remaining: 0, limit: 100 });
+ configInternal.lastEmittedQuotaRemaining = 0;
+ configInternal.lastEmittedQuotaLimit = 100;
+ configInternal.lastQuotaFetchTime = 12345;
+ configInternal.hasAccessToPreviewModel = true;
+
+ // Listen for quota event
+ const emitQuotaSpy = vi.spyOn(coreEvents, 'emitQuotaChanged');
+
+ // 2. Trigger session change
+ config.setSessionId('session-two');
+
+ // 3. Verify EVERYTHING is reset
+ expect(config.getSessionId()).toBe('session-two');
+ expect(config.getApprovedPlanPath()).toBeUndefined();
+ expect(config.topicState.getTopic()).toBeUndefined();
+ expect(config.topicState.getIntent()).toBeUndefined();
+ expect(config.getSkillManager().isSkillActive('old-skill')).toBe(false);
+ expect(config.getTrackerService()).not.toBe(oldTrackerService);
+ expect(
+ config.getModelAvailabilityService().snapshot('model-1').available,
+ ).toBe(true);
+ expect(config.getLatestApiRequest()).toBeUndefined();
+
+ // Quota resets
+ expect(configInternal.modelQuotas.size).toBe(0);
+ expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
+ expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
+ expect(configInternal.lastQuotaFetchTime).toBe(0);
+ expect(configInternal.hasAccessToPreviewModel).toBeNull();
+
+ // Event emission
+ expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
+ });
});
describe('GemmaModelRouterSettings', () => {
@@ -3615,6 +3660,168 @@ describe('Config JIT Initialization', () => {
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
).toBe(false);
});
+
+ it('should NOT allow isPathAllowed to write into the auto-memory inbox', () => {
+ // /.inbox/ is owned by the extraction agent and the
+ // /memory inbox review flow. The main agent must not be able to drop
+ // patches in there directly, even though it falls inside .
+ // We bypass Config.initialize() (the GitService init path is independently
+ // flaky in this suite) by spying on the storage methods isPathAllowed
+ // actually consults.
+ const params: ConfigParameters = {
+ sessionId: 'test-session',
+ targetDir: '/tmp/test',
+ debugMode: false,
+ model: 'test-model',
+ cwd: '/tmp/test',
+ };
+
+ config = new Config(params);
+
+ const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
+ const fakeProjectTempDir = '/tmp/test-fake-temp';
+ vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
+ fakeMemoryTempDir,
+ );
+ vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
+ fakeProjectTempDir,
+ );
+
+ const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
+
+ // The inbox directory itself and any path under it are denied.
+ expect(config.isPathAllowed(inboxRoot)).toBe(false);
+ expect(
+ config.isPathAllowed(path.join(inboxRoot, 'private', 'foo.patch')),
+ ).toBe(false);
+ expect(
+ config.isPathAllowed(path.join(inboxRoot, 'global', 'bar.patch')),
+ ).toBe(false);
+
+ // Sibling files under stay reachable so the main
+ // agent can edit MEMORY.md and topic notes directly.
+ expect(
+ config.isPathAllowed(path.join(fakeMemoryTempDir, 'MEMORY.md')),
+ ).toBe(true);
+ expect(
+ config.isPathAllowed(path.join(fakeMemoryTempDir, 'some-topic.md')),
+ ).toBe(true);
+ });
+
+ it('should allow scoped extraction access only to canonical inbox patches', () => {
+ const params: ConfigParameters = {
+ sessionId: 'test-session',
+ targetDir: '/tmp/test',
+ debugMode: false,
+ model: 'test-model',
+ cwd: '/tmp/test',
+ };
+
+ config = new Config(params);
+
+ const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
+ const fakeProjectTempDir = '/tmp/test-fake-temp';
+ vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
+ fakeMemoryTempDir,
+ );
+ vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
+ fakeProjectTempDir,
+ );
+
+ const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
+ const privateExtractionPatch = path.join(
+ inboxRoot,
+ 'private',
+ 'extraction.patch',
+ );
+ const globalExtractionPatch = path.join(
+ inboxRoot,
+ 'global',
+ 'extraction.patch',
+ );
+
+ expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
+
+ runWithScopedMemoryInboxAccess(() => {
+ expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
+ expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
+ expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
+ expect(
+ config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
+ ).toBe(false);
+ expect(
+ config.isPathAllowed(
+ path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
+ ),
+ ).toBe(false);
+ });
+
+ expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
+ });
+
+ it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
+ const params: ConfigParameters = {
+ sessionId: 'test-session',
+ targetDir: '/tmp/test',
+ debugMode: false,
+ model: 'test-model',
+ cwd: '/tmp/test',
+ };
+
+ config = new Config(params);
+
+ const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
+ const fakeProjectTempDir = '/tmp/test-fake-temp';
+ const fakeSkillsMemoryDir = path.join(fakeMemoryTempDir, 'skills');
+ vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
+ fakeMemoryTempDir,
+ );
+ vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
+ fakeProjectTempDir,
+ );
+ vi.spyOn(config.storage, 'getProjectSkillsMemoryDir').mockReturnValue(
+ fakeSkillsMemoryDir,
+ );
+
+ const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
+ const privateExtractionPatch = path.join(
+ inboxRoot,
+ 'private',
+ 'extraction.patch',
+ );
+ const skillArtifact = path.join(
+ fakeSkillsMemoryDir,
+ 'my-skill',
+ 'SKILL.md',
+ );
+ const activeMemoryPath = path.join(fakeMemoryTempDir, 'MEMORY.md');
+ const projectTempPath = path.join(fakeProjectTempDir, 'logs', 'run.log');
+ const workspaceMemoryPath = path.join('/tmp/test', 'GEMINI.md');
+
+ expect(config.validatePathAccess(activeMemoryPath)).toBeNull();
+
+ runWithScopedAutoMemoryExtractionWriteAccess(() => {
+ expect(config.validatePathAccess(skillArtifact)).toBeNull();
+ expect(config.validatePathAccess(activeMemoryPath)).toContain(
+ 'Auto-memory extraction write denied',
+ );
+ expect(config.validatePathAccess(projectTempPath)).toContain(
+ 'Auto-memory extraction write denied',
+ );
+ expect(config.validatePathAccess(workspaceMemoryPath)).toContain(
+ 'Auto-memory extraction write denied',
+ );
+
+ // Reads still use the normal workspace/temp allowlists.
+ expect(config.validatePathAccess(activeMemoryPath, 'read')).toBeNull();
+ });
+
+ runWithScopedMemoryInboxAccess(() => {
+ runWithScopedAutoMemoryExtractionWriteAccess(() => {
+ expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
+ });
+ });
+ });
});
describe('isAutoMemoryEnabled', () => {
@@ -3673,7 +3880,7 @@ describe('Config JIT Initialization', () => {
expect(config.getExperimentalGemma()).toBe(false);
});
- it('should return false when experimentalGemma is not provided', () => {
+ it('should return true when experimentalGemma is not provided', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
@@ -3683,7 +3890,7 @@ describe('Config JIT Initialization', () => {
};
config = new Config(params);
- expect(config.getExperimentalGemma()).toBe(false);
+ expect(config.getExperimentalGemma()).toBe(true);
});
it('should be independent of experimentalMemoryV2', () => {
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 11f7a24841..985915e6ff 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -140,7 +140,11 @@ import type { GenerateContentParameters } from '@google/genai';
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
-import { getWorkspaceContextOverride } from './scoped-config.js';
+import {
+ getWorkspaceContextOverride,
+ hasScopedAutoMemoryExtractionWriteAccess,
+ hasScopedMemoryInboxAccess,
+} from './scoped-config.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
@@ -681,7 +685,6 @@ export interface ConfigParameters {
gemmaModelRouter?: GemmaModelRouterSettings;
adk?: ADKSettings;
disableModelRouterForAuth?: AuthType[];
- continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
maxAttempts?: number;
enableShellOutputEfficiency?: boolean;
@@ -911,7 +914,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly agentSessionNoninteractiveEnabled: boolean;
private readonly agentSessionInteractiveEnabled: boolean;
- private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
@@ -1179,7 +1181,7 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
- this.experimentalGemma = params.experimentalGemma ?? false;
+ this.experimentalGemma = params.experimentalGemma ?? true;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
@@ -1288,7 +1290,6 @@ export class Config implements McpContext, AgentLoopContext {
this.enableHooks = params.enableHooks ?? true;
this.disabledHooks = params.disabledHooks ?? [];
- this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
this.enableShellOutputEfficiency =
params.enableShellOutputEfficiency ?? true;
this.shellToolInactivityTimeout =
@@ -1806,6 +1807,24 @@ export class Config implements McpContext, AgentLoopContext {
this._sessionId = sessionId;
this.storage.setSessionId(sessionId);
this.trackerService = undefined;
+ this.approvedPlanPath = undefined;
+ this.topicState.reset();
+ this.skillManager.reset();
+ this.latestApiRequest = undefined;
+ this.lastModeSwitchTime = performance.now();
+ this.compressionTruncationCounter = 0;
+ this.quotaErrorOccurred = false;
+ this.creditsNotificationShown = false;
+ this.modelAvailabilityService.reset();
+ this.modelQuotas.clear();
+ this.lastRetrievedQuota = undefined;
+ this.lastQuotaFetchTime = 0;
+ this.hasAccessToPreviewModel = null;
+
+ // Force an event emission to clear the UI display
+ coreEvents.emitQuotaChanged(undefined, undefined, undefined);
+ this.lastEmittedQuotaRemaining = undefined;
+ this.lastEmittedQuotaLimit = undefined;
if (previousPlansDir) {
this.refreshSessionScopedPlansDirectory(previousPlansDir);
@@ -1814,7 +1833,6 @@ export class Config implements McpContext, AgentLoopContext {
resetNewSessionState(sessionId: string): void {
this.setSessionId(sessionId);
- this.approvedPlanPath = undefined;
}
setTerminalBackground(terminalBackground: string | undefined): void {
@@ -2683,26 +2701,28 @@ export class Config implements McpContext, AgentLoopContext {
this,
new ApprovalModeSwitchEvent(currentMode, mode),
);
- }
- this.policyEngine.setApprovalMode(mode);
- this.refreshSandboxManager();
+ this.policyEngine.setApprovalMode(mode);
+ this.refreshSandboxManager();
+ coreEvents.emit(CoreEvent.ApprovalModeChanged, {
+ sessionId: this.getSessionId(),
+ mode,
+ });
- const isPlanModeTransition =
- currentMode !== mode &&
- (currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN);
- const isYoloModeTransition =
- currentMode !== mode &&
- (currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO);
+ const isPlanModeTransition =
+ currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN;
+ const isYoloModeTransition =
+ currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO;
- if (isPlanModeTransition || isYoloModeTransition) {
- if (this._geminiClient?.isInitialized()) {
- this._geminiClient.clearCurrentSequenceModel();
- this._geminiClient.setTools().catch((err) => {
- debugLogger.error('Failed to update tools', err);
- });
+ if (isPlanModeTransition || isYoloModeTransition) {
+ if (this._geminiClient?.isInitialized()) {
+ this._geminiClient.clearCurrentSequenceModel();
+ this._geminiClient.setTools().catch((err) => {
+ debugLogger.error('Failed to update tools', err);
+ });
+ }
+ this.updateSystemInstructionIfInitialized();
}
- this.updateSystemInstructionIfInitialized();
}
}
@@ -3047,6 +3067,52 @@ export class Config implements McpContext, AgentLoopContext {
this.ideMode = value;
}
+ private isScopedMemoryInboxPatchPathAllowed(
+ absolutePath: string,
+ resolvedPath: string,
+ inboxRoot: string,
+ ): boolean {
+ if (!hasScopedMemoryInboxAccess()) {
+ return false;
+ }
+
+ const normalizedPath = path.resolve(absolutePath);
+ const isCanonicalPatchPath = (['private', 'global'] as const).some(
+ (kind) =>
+ normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
+ );
+ if (!isCanonicalPatchPath) {
+ return false;
+ }
+
+ const resolvedMemoryRoot = resolveToRealPath(
+ this.storage.getProjectMemoryTempDir(),
+ );
+ return isSubpath(resolvedMemoryRoot, resolvedPath);
+ }
+
+ private isScopedAutoMemoryExtractionWritePathAllowed(
+ absolutePath: string,
+ resolvedPath: string,
+ ): boolean {
+ if (!hasScopedAutoMemoryExtractionWriteAccess()) {
+ return false;
+ }
+
+ const resolvedSkillsMemoryDir = resolveToRealPath(
+ this.storage.getProjectSkillsMemoryDir(),
+ );
+ if (isSubpath(resolvedSkillsMemoryDir, resolvedPath)) {
+ return true;
+ }
+
+ return this.isScopedMemoryInboxPatchPathAllowed(
+ absolutePath,
+ resolvedPath,
+ path.join(this.storage.getProjectMemoryTempDir(), '.inbox'),
+ );
+ }
+
/**
* Get the current FileSystemService
*/
@@ -3061,12 +3127,48 @@ export class Config implements McpContext, AgentLoopContext {
* file (the latter is the only file under `~/.gemini/` that is reachable ā
* settings, credentials, keybindings, etc. remain disallowed).
*
+ * One subtree is *carved back out*: `/.inbox/` is owned by
+ * the auto-memory extraction agent and the `/memory inbox` review flow. The
+ * main agent is denied access to it even though it falls inside the project
+ * temp dir; the extraction agent receives a narrow execution-scoped exception
+ * for `.inbox/{private,global}/extraction.patch`.
+ *
* @param absolutePath The absolute path to check.
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
const resolvedPath = resolveToRealPath(absolutePath);
+ // The auto-memory inbox (`/.inbox/`) is owned by the
+ // background extraction agent and the `/memory inbox` review flow. The
+ // main agent must NOT drop files into it directly (that would let the
+ // model bypass review). Deny first, even if the path also satisfies the
+ // workspace or project-temp allowlists below.
+ const inboxRoot = path.join(
+ this.storage.getProjectMemoryTempDir(),
+ '.inbox',
+ );
+ const resolvedInboxRoot = resolveToRealPath(inboxRoot);
+ const normalizedPath = path.resolve(absolutePath);
+ const normalizedInboxRoot = path.resolve(inboxRoot);
+ if (
+ resolvedPath === resolvedInboxRoot ||
+ isSubpath(resolvedInboxRoot, resolvedPath) ||
+ normalizedPath === normalizedInboxRoot ||
+ isSubpath(normalizedInboxRoot, normalizedPath)
+ ) {
+ if (
+ this.isScopedMemoryInboxPatchPathAllowed(
+ absolutePath,
+ resolvedPath,
+ inboxRoot,
+ )
+ ) {
+ return true;
+ }
+ return false;
+ }
+
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
return true;
@@ -3106,6 +3208,19 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
checkType: 'read' | 'write' = 'write',
): string | null {
+ if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
+ const resolvedPath = resolveToRealPath(absolutePath);
+ if (
+ this.isScopedAutoMemoryExtractionWritePathAllowed(
+ absolutePath,
+ resolvedPath,
+ )
+ ) {
+ return null;
+ }
+ return `Auto-memory extraction write denied: Attempted path "${absolutePath}" is outside the extraction write allowlist. Extraction may only write extracted skills under ${this.storage.getProjectSkillsMemoryDir()} and canonical inbox patches under ${path.join(this.storage.getProjectMemoryTempDir(), '.inbox', '{private,global}', 'extraction.patch')}.`;
+ }
+
// For read operations, check read-only paths first
if (checkType === 'read') {
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
@@ -3449,10 +3564,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.skipNextSpeakerCheck;
}
- getContinueOnFailedApiCall(): boolean {
- return this.continueOnFailedApiCall;
- }
-
getRetryFetchErrors(): boolean {
return this.retryFetchErrors;
}
diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts
index 51846262dc..d49f3305c2 100644
--- a/packages/core/src/config/models.test.ts
+++ b/packages/core/src/config/models.test.ts
@@ -595,9 +595,9 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
});
- it('should return true for Gemma 4 models only when experimentalGemma is true', () => {
- expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(false);
- expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(false);
+ it('should return true for Gemma 4 models when experimentalGemma is not provided (defaults to true)', () => {
+ expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(true);
+ expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(true);
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
true,
);
diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts
index 6e936182cd..69541d1aca 100644
--- a/packages/core/src/config/models.ts
+++ b/packages/core/src/config/models.ts
@@ -455,7 +455,7 @@ export function isActiveModel(
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
- experimentalGemma: boolean = false,
+ experimentalGemma: boolean = true,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
return false;
diff --git a/packages/core/src/config/scoped-config.ts b/packages/core/src/config/scoped-config.ts
index 90cdea2da6..e44a73d4a2 100644
--- a/packages/core/src/config/scoped-config.ts
+++ b/packages/core/src/config/scoped-config.ts
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
*/
const workspaceContextOverride = new AsyncLocalStorage();
+const memoryInboxAccessOverride = new AsyncLocalStorage();
+const autoMemoryExtractionWriteAccessOverride =
+ new AsyncLocalStorage();
/**
* Returns the current workspace context override, if any.
@@ -44,6 +47,42 @@ export function runWithScopedWorkspaceContext(
return workspaceContextOverride.run(scopedContext, fn);
}
+/**
+ * Returns true when the current async execution is allowed to access the
+ * canonical auto-memory inbox patch files.
+ */
+export function hasScopedMemoryInboxAccess(): boolean {
+ return memoryInboxAccessOverride.getStore() === true;
+}
+
+/**
+ * Runs a function with access to the canonical auto-memory inbox patch files.
+ * This is intended for the background extraction agent only; the main agent
+ * continues to have the inbox carved out of its normal temp-dir access.
+ */
+export function runWithScopedMemoryInboxAccess(fn: () => T): T {
+ return memoryInboxAccessOverride.run(true, fn);
+}
+
+/**
+ * Returns true when the current async execution is using the narrow
+ * auto-memory extraction write allowlist.
+ */
+export function hasScopedAutoMemoryExtractionWriteAccess(): boolean {
+ return autoMemoryExtractionWriteAccessOverride.getStore() === true;
+}
+
+/**
+ * Runs a function with the auto-memory extraction write allowlist active.
+ * This prevents the background extractor from writing active memory files
+ * directly; it may only write extracted skills and canonical inbox patches.
+ */
+export function runWithScopedAutoMemoryExtractionWriteAccess(
+ fn: () => T,
+): T {
+ return autoMemoryExtractionWriteAccessOverride.run(true, fn);
+}
+
/**
* Creates a {@link WorkspaceContext} that extends a parent's directories
* with additional ones.
diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts
index 5a40648a4a..fcc3cddc84 100644
--- a/packages/core/src/config/storage.ts
+++ b/packages/core/src/config/storage.ts
@@ -106,10 +106,6 @@ export class Storage {
return path.join(Storage.getGlobalAgentsDir(), 'skills');
}
- static getGlobalMemoryFilePath(): string {
- return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
- }
-
static getUserPoliciesDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'policies');
}
diff --git a/packages/core/src/context/config/profiles.ts b/packages/core/src/context/config/profiles.ts
index e938668500..3948a85f64 100644
--- a/packages/core/src/context/config/profiles.ts
+++ b/packages/core/src/context/config/profiles.ts
@@ -47,6 +47,7 @@ function resolveProcessorOptions(
}
export interface ContextProfile {
+ name: string;
config: ContextManagementConfig;
buildPipelines: (
env: ContextEnvironment,
@@ -56,6 +57,10 @@ export interface ContextProfile {
env: ContextEnvironment,
config?: ContextManagementConfig,
) => AsyncPipelineDef[];
+ sentinels?: {
+ continuation?: string;
+ lostToolResponse?: string;
+ };
}
/**
@@ -63,6 +68,12 @@ export interface ContextProfile {
* Optimized for safety, precision, and reliable summarization.
*/
export const generalistProfile: ContextProfile = {
+ name: 'Generalist (Default)',
+ sentinels: {
+ continuation: '[Continuing from previous AI thoughts...]',
+ lostToolResponse:
+ 'The tool execution result was lost due to context management truncation.',
+ },
config: {
budget: {
retainedTokens: 65000,
@@ -106,14 +117,14 @@ export const generalistProfile: ContextProfile = {
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
- nodeThresholdTokens: 3000,
+ nodeThresholdTokens: 1000,
}),
),
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
- maxTokensPerNode: 2000,
+ maxTokensPerNode: 1200,
}),
),
],
@@ -158,6 +169,7 @@ export const generalistProfile: ContextProfile = {
* within a few conversational turns.
*/
export const stressTestProfile: ContextProfile = {
+ name: 'Stress Test',
config: {
budget: {
retainedTokens: 4000,
diff --git a/packages/core/src/context/contextCompressionService.test.ts b/packages/core/src/context/contextCompressionService.test.ts
index bb376e4da8..cba310891a 100644
--- a/packages/core/src/context/contextCompressionService.test.ts
+++ b/packages/core/src/context/contextCompressionService.test.ts
@@ -14,9 +14,13 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(),
}));
-vi.mock('node:fs', () => ({
- existsSync: vi.fn(),
-}));
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ existsSync: vi.fn(),
+ };
+});
describe('ContextCompressionService', () => {
let mockConfig: Partial;
diff --git a/packages/core/src/context/contextManager.barrier.test.ts b/packages/core/src/context/contextManager.barrier.test.ts
index f5273b79d8..c3a7298ddc 100644
--- a/packages/core/src/context/contextManager.barrier.test.ts
+++ b/packages/core/src/context/contextManager.barrier.test.ts
@@ -51,17 +51,18 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
const rawHistoryLength = chatHistory.get().length;
// 5. Project History (Triggers Sync Barrier)
- const projection = await contextManager.renderHistory();
+ const { history: projection } = await contextManager.renderHistory();
// 6. Assertions
// The barrier should have dropped several older episodes to get under 150k.
expect(projection.length).toBeLessThan(rawHistoryLength);
- // Verify Episode 0 (System) is perfectly preserved at the front
-
+ // Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
expect(projection[0].role).toBe('user');
- expect(projection[0].parts![0].text).toBe('System prompt');
+ expect(projection[0].parts![0].text).toBe(
+ '[Continuing from previous AI thoughts...]',
+ );
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
@@ -70,8 +71,14 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
);
// Verify the latest turn is perfectly preserved at the back
- const lastUser = contentNodes[contentNodes.length - 2];
- const lastModel = contentNodes[contentNodes.length - 1];
+ // Note: The HistoryHardener appends a "Please continue." user turn if we end on model,
+ // so we look at the turns before the sentinel.
+ const lastSentinel = contentNodes[contentNodes.length - 1];
+ const lastModel = contentNodes[contentNodes.length - 2];
+ const lastUser = contentNodes[contentNodes.length - 3];
+
+ expect(lastSentinel.role).toBe('user');
+ expect(lastSentinel.parts![0].text).toBe('Please continue.');
expect(lastUser.role).toBe('user');
expect(lastUser.parts![0].text).toBe('Final question.');
diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts
index fc03a9c127..3042789242 100644
--- a/packages/core/src/context/contextManager.ts
+++ b/packages/core/src/context/contextManager.ts
@@ -6,7 +6,7 @@
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
-import type { ConcreteNode } from './graph/types.js';
+import { isToolExecution, type ConcreteNode } from './graph/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './pipeline/environment.js';
@@ -15,6 +15,9 @@ import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
import { HistoryObserver } from './historyObserver.js';
import { render } from './graph/render.js';
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
+import { debugLogger } from '../utils/debugLogger.js';
+import { hardenHistory } from '../utils/historyHardening.js';
+import { checkContextInvariants } from './utils/invariantChecker.js';
export class ContextManager {
// The master state containing the pristine graph and current active graph.
@@ -27,21 +30,30 @@ export class ContextManager {
private readonly orchestrator: PipelineOrchestrator;
private readonly historyObserver: HistoryObserver;
+ // Cache for Anomaly 3 (Redundant Renders)
+ private lastRenderCache?: {
+ nodesHash: string;
+ result: { history: Content[]; didApplyManagement: boolean };
+ };
+
constructor(
private readonly sidecar: ContextProfile,
private readonly env: ContextEnvironment,
private readonly tracer: ContextTracer,
orchestrator: PipelineOrchestrator,
chatHistory: AgentChatHistory,
+ private readonly headerProvider?: () => Promise,
) {
this.eventBus = env.eventBus;
this.orchestrator = orchestrator;
+ // Provide the orchestrator with a way to fetch the latest nodes from the live buffer
+ this.orchestrator.setNodeProvider(() => this.buffer.nodes);
+
this.historyObserver = new HistoryObserver(
chatHistory,
this.env.eventBus,
this.tracer,
- this.env.tokenCalculator,
this.env.graphMapper,
);
@@ -69,6 +81,13 @@ export class ContextManager {
this.historyObserver.start();
}
+ /**
+ * Returns a promise that resolves when all currently executing async pipelines have finished.
+ */
+ async waitForPipelines(): Promise {
+ return this.orchestrator.waitForPipelines();
+ }
+
/**
* Safely stops background async pipelines and clears event listeners.
*/
@@ -98,6 +117,15 @@ export class ContextManager {
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
const agedOutNodes = new Set();
let rollingTokens = 0;
+
+ // Identify active tool calls that must NEVER be truncated
+ const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
+ if (protectedIds.size > 0) {
+ debugLogger.log(
+ `[ContextManager] Pinning ${protectedIds.size} active tool call nodes to prevent truncation.`,
+ );
+ }
+
// Walk backwards finding nodes that fall out of the retained budget
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
const node = this.buffer.nodes[i];
@@ -105,7 +133,10 @@ export class ContextManager {
node,
]);
if (rollingTokens > this.sidecar.config.budget.retainedTokens) {
- agedOutNodes.add(node.id);
+ // Only age out if not protected
+ if (!protectedIds.has(node.id)) {
+ agedOutNodes.add(node.id);
+ }
}
}
@@ -123,6 +154,54 @@ export class ContextManager {
}
}
+ /**
+ * Identifies 'pinned' nodes that should not be truncated.
+ * This includes:
+ * 1. The entire last turn (Recent context).
+ * 2. Active tool calls (calls without responses in the graph).
+ */
+ private getProtectedNodeIds(
+ nodes: readonly ConcreteNode[],
+ extraProtectedIds: Set = new Set(),
+ ): Map {
+ const protectionMap = new Map();
+ if (nodes.length === 0) return protectionMap;
+
+ // 1. Identify all nodes belonging to the last turn (Recent context)
+ const lastNode = nodes[nodes.length - 1];
+ const lastTurnId = lastNode.turnId;
+
+ for (const node of nodes) {
+ if (node.turnId === lastTurnId) {
+ protectionMap.set(node.id, 'recent_turn');
+ }
+ }
+
+ // 2. Identify active tool calls that must NEVER be truncated
+ const calls = nodes.filter((n) => isToolExecution(n) && n.role === 'model');
+ const responses = new Set(
+ nodes
+ .filter((n) => isToolExecution(n) && n.role === 'user')
+ .map((n) => n.payload.functionResponse?.id)
+ .filter((id): id is string => !!id),
+ );
+
+ for (const call of calls) {
+ const id = call.payload.functionCall?.id;
+ // If we have a call but no response in the current graph, it's 'in flight'
+ if (id && !responses.has(id)) {
+ protectionMap.set(call.id, 'in_flight_tool_call');
+ }
+ }
+
+ // 3. Any externally requested protections
+ for (const id of extraProtectedIds) {
+ protectionMap.set(id, 'external_active_task');
+ }
+
+ return protectionMap;
+ }
+
/**
* Retrieves the raw, uncompressed Episodic Context Graph graph.
* Useful for internal tool rendering (like the trace viewer).
@@ -157,22 +236,78 @@ export class ContextManager {
* This is the primary method called by the agent framework before sending a request.
*/
async renderHistory(
+ pendingRequest?: Content,
activeTaskIds: Set = new Set(),
- ): Promise {
+ ): Promise<{ history: Content[]; didApplyManagement: boolean }> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
+ // 1. Synchronous Pressure Barrier: Wait for background management pipelines to finish.
+ // This ensures that the render sees the results of recent pushes (Anomaly 2).
+ await this.orchestrator.waitForPipelines();
+
+ let nodes = this.buffer.nodes;
+
+ // If we have a pending request, we need to build a 'preview' graph for this render.
+ if (pendingRequest) {
+ const previewNodes = this.env.graphMapper.applyEvent({
+ type: 'PUSH',
+ payload: [pendingRequest],
+ });
+ nodes = [...nodes, ...previewNodes];
+ }
+
+ // 2. Fetch Header and calculate tokens
+ const header = this.headerProvider
+ ? await this.headerProvider()
+ : undefined;
+ const headerTokens = header
+ ? this.env.tokenCalculator.calculateContentTokens(header)
+ : 0;
+
+ // 3. Cache Check (Anomaly 3): If nodes haven't changed, return previous result.
+ // We combine the graph hash with a hash of the header to ensure total freshness.
+ const graphHash = nodes.map((n) => n.id).join('|');
+ const headerHash = header ? JSON.stringify(header.parts) : 'no-header';
+ const totalHash = `${graphHash}::${headerHash}`;
+
+ if (this.lastRenderCache?.nodesHash === totalHash) {
+ debugLogger.log(
+ '[ContextManager] Render cache hit. Skipping redundant render.',
+ );
+ return this.lastRenderCache.result;
+ }
+
+ const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
+
// Apply final GC Backstop pressure barrier synchronously before mapping
- const finalHistory = await render(
- this.buffer.nodes,
+ const { history: renderedHistory, didApplyManagement } = await render(
+ nodes,
this.orchestrator,
this.sidecar,
this.tracer,
this.env,
- activeTaskIds,
+ protectionReasons,
+ headerTokens,
);
+ // Structural validation in debug mode
+ checkContextInvariants(this.buffer.nodes, 'RenderHistory');
+
this.tracer.logEvent('ContextManager', 'Finished rendering');
- return finalHistory;
+ const combinedHistory = header
+ ? [header, ...renderedHistory]
+ : renderedHistory;
+
+ const result = {
+ history: hardenHistory(combinedHistory, {
+ sentinels: this.sidecar.sentinels,
+ }),
+ didApplyManagement,
+ };
+
+ // Update cache
+ this.lastRenderCache = { nodesHash: totalHash, result };
+ return result;
}
}
diff --git a/packages/core/src/context/graph/behaviorRegistry.ts b/packages/core/src/context/graph/behaviorRegistry.ts
index c0c411c8cd..e206a10eb1 100644
--- a/packages/core/src/context/graph/behaviorRegistry.ts
+++ b/packages/core/src/context/graph/behaviorRegistry.ts
@@ -3,21 +3,11 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Content, Part } from '@google/genai';
-import type { ConcreteNode } from './types.js';
-
-export interface NodeSerializationWriter {
- appendContent(content: Content): void;
- appendModelPart(part: Part): void;
- appendUserPart(part: Part): void;
- flushModelParts(): void;
-}
+import type { Part } from '@google/genai';
+import type { ConcreteNode, NodeType } from './types.js';
export interface NodeBehavior {
- readonly type: T['type'];
-
- /** Serializes the node into the Gemini Content structure. */
- serialize(node: T, writer: NodeSerializationWriter): void;
+ readonly type: NodeType;
/**
* Generates a structural representation of the node for the purpose
@@ -27,13 +17,13 @@ export interface NodeBehavior {
}
export class NodeBehaviorRegistry {
- private readonly behaviors = new Map>();
+ private readonly behaviors = new Map>();
register(behavior: NodeBehavior) {
this.behaviors.set(behavior.type, behavior);
}
- get(type: string): NodeBehavior {
+ get(type: NodeType): NodeBehavior {
const behavior = this.behaviors.get(type);
if (!behavior) {
throw new Error(`Unregistered Node type: ${type}`);
diff --git a/packages/core/src/context/graph/builtinBehaviors.ts b/packages/core/src/context/graph/builtinBehaviors.ts
index 61741d10ba..dc6303cb47 100644
--- a/packages/core/src/context/graph/builtinBehaviors.ts
+++ b/packages/core/src/context/graph/builtinBehaviors.ts
@@ -3,160 +3,72 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Part } from '@google/genai';
import type { NodeBehavior, NodeBehaviorRegistry } from './behaviorRegistry.js';
-import type {
- UserPrompt,
- AgentThought,
- ToolExecution,
- MaskedTool,
- AgentYield,
- Snapshot,
- RollingSummary,
- SystemEvent,
+import {
+ type UserPrompt,
+ type AgentThought,
+ type ToolExecution,
+ type MaskedTool,
+ type AgentYield,
+ type Snapshot,
+ type RollingSummary,
+ type SystemEvent,
+ NodeType,
} from './types.js';
export const UserPromptBehavior: NodeBehavior = {
- type: 'USER_PROMPT',
- getEstimatableParts(prompt) {
- const parts: Part[] = [];
- for (const sp of prompt.semanticParts) {
- switch (sp.type) {
- case 'text':
- parts.push({ text: sp.text });
- break;
- case 'inline_data':
- parts.push({ inlineData: { mimeType: sp.mimeType, data: sp.data } });
- break;
- case 'file_data':
- parts.push({
- fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri },
- });
- break;
- case 'raw_part':
- parts.push(sp.part);
- break;
- default:
- break;
- }
- }
- return parts;
- },
- serialize(prompt, writer) {
- const parts = this.getEstimatableParts(prompt);
- if (parts.length > 0) {
- writer.flushModelParts();
- writer.appendContent({ role: 'user', parts });
- }
+ type: NodeType.USER_PROMPT,
+ getEstimatableParts(node) {
+ return [node.payload];
},
};
export const AgentThoughtBehavior: NodeBehavior = {
- type: 'AGENT_THOUGHT',
- getEstimatableParts(thought) {
- return [{ text: thought.text }];
- },
- serialize(thought, writer) {
- writer.appendModelPart({ text: thought.text });
+ type: NodeType.AGENT_THOUGHT,
+ getEstimatableParts(node) {
+ return [node.payload];
},
};
export const ToolExecutionBehavior: NodeBehavior = {
- type: 'TOOL_EXECUTION',
- getEstimatableParts(tool) {
- return [
- { functionCall: { id: tool.id, name: tool.toolName, args: tool.intent } },
- {
- functionResponse: {
- id: tool.id,
- name: tool.toolName,
- response:
- typeof tool.observation === 'string'
- ? { message: tool.observation }
- : tool.observation,
- },
- },
- ];
- },
- serialize(tool, writer) {
- const parts = this.getEstimatableParts(tool);
- writer.appendModelPart(parts[0]);
- writer.flushModelParts();
- writer.appendUserPart(parts[1]);
+ type: NodeType.TOOL_EXECUTION,
+ getEstimatableParts(node) {
+ return [node.payload];
},
};
export const MaskedToolBehavior: NodeBehavior = {
- type: 'MASKED_TOOL',
- getEstimatableParts(tool) {
- return [
- {
- functionCall: {
- id: tool.id,
- name: tool.toolName,
- args: tool.intent ?? {},
- },
- },
- {
- functionResponse: {
- id: tool.id,
- name: tool.toolName,
- response:
- typeof tool.observation === 'string'
- ? { message: tool.observation }
- : (tool.observation ?? {}),
- },
- },
- ];
- },
- serialize(tool, writer) {
- const parts = this.getEstimatableParts(tool);
- writer.appendModelPart(parts[0]);
- writer.flushModelParts();
- writer.appendUserPart(parts[1]);
+ type: NodeType.MASKED_TOOL,
+ getEstimatableParts(node) {
+ return [node.payload];
},
};
export const AgentYieldBehavior: NodeBehavior = {
- type: 'AGENT_YIELD',
- getEstimatableParts(yieldNode) {
- return [{ text: yieldNode.text }];
- },
- serialize() {
- // AGENT_YIELD is a synthetic marker node used for internal graph tracking.
- // We intentionally do NOT serialize it to the LLM to prevent prompt corruption.
+ type: NodeType.AGENT_YIELD,
+ getEstimatableParts() {
+ return [];
},
};
export const SystemEventBehavior: NodeBehavior = {
- type: 'SYSTEM_EVENT',
- getEstimatableParts() {
- return [];
- },
- serialize(node, writer) {
- writer.flushModelParts();
+ type: NodeType.SYSTEM_EVENT,
+ getEstimatableParts(node) {
+ return [node.payload];
},
};
export const SnapshotBehavior: NodeBehavior = {
- type: 'SNAPSHOT',
+ type: NodeType.SNAPSHOT,
getEstimatableParts(node) {
- return [{ text: node.text }];
- },
- serialize(node, writer) {
- writer.flushModelParts();
- writer.appendUserPart({ text: node.text });
+ return [node.payload];
},
};
export const RollingSummaryBehavior: NodeBehavior = {
- type: 'ROLLING_SUMMARY',
+ type: NodeType.ROLLING_SUMMARY,
getEstimatableParts(node) {
- return [{ text: node.text }];
- },
- serialize(node, writer) {
- writer.flushModelParts();
- writer.appendUserPart({ text: node.text });
+ return [node.payload];
},
};
diff --git a/packages/core/src/context/graph/fromGraph.ts b/packages/core/src/context/graph/fromGraph.ts
index a83783befe..3a078c01ee 100644
--- a/packages/core/src/context/graph/fromGraph.ts
+++ b/packages/core/src/context/graph/fromGraph.ts
@@ -3,52 +3,53 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Content, Part } from '@google/genai';
+
+import type { Content } from '@google/genai';
import type { ConcreteNode } from './types.js';
-import type {
- NodeSerializationWriter,
- NodeBehaviorRegistry,
-} from './behaviorRegistry.js';
+import { debugLogger } from '../../utils/debugLogger.js';
-class NodeSerializer implements NodeSerializationWriter {
- private history: Content[] = [];
- private currentModelParts: Part[] = [];
+/**
+ * Reconstructs a valid Gemini Chat History from a list of Concrete Nodes.
+ * This process is "role-alternation-aware" and uses turnId to
+ * preserve original turn boundaries even if multiple turns have the same role.
+ */
+export function fromGraph(nodes: readonly ConcreteNode[]): Content[] {
+ debugLogger.log(
+ `[fromGraph] Reconstructing history from ${nodes.length} nodes`,
+ );
- appendContent(content: Content) {
- this.flushModelParts();
- this.history.push(content);
- }
+ const history: Content[] = [];
+ let currentTurn: (Content & { _turnId?: string }) | null = null;
- appendModelPart(part: Part) {
- this.currentModelParts.push(part);
- }
+ for (const node of nodes) {
+ const turnId = node.turnId;
- appendUserPart(part: Part) {
- this.flushModelParts();
- this.history.push({ role: 'user', parts: [part] });
- }
-
- flushModelParts() {
- if (this.currentModelParts.length > 0) {
- this.history.push({ role: 'model', parts: [...this.currentModelParts] });
- this.currentModelParts = [];
+ // We start a new turn if:
+ // 1. We don't have a current turn.
+ // 2. The role changes (Standard alternation).
+ // 3. The turnId changes (Preserving distinct turns of the same role).
+ if (
+ !currentTurn ||
+ currentTurn.role !== node.role ||
+ currentTurn._turnId !== turnId
+ ) {
+ currentTurn = {
+ role: node.role,
+ parts: [node.payload],
+ _turnId: turnId,
+ };
+ history.push(currentTurn);
+ } else {
+ currentTurn.parts = [...(currentTurn.parts || []), node.payload];
}
}
- getContents(): Content[] {
- this.flushModelParts();
- return this.history;
+ // Final cleanup: remove our internal tracking field
+ for (const turn of history) {
+ const t = turn as Content & { _turnId?: string };
+ delete t._turnId;
}
-}
-export function fromGraph(
- nodes: readonly ConcreteNode[],
- registry: NodeBehaviorRegistry,
-): Content[] {
- const writer = new NodeSerializer();
- for (const node of nodes) {
- const behavior = registry.get(node.type);
- behavior.serialize(node, writer);
- }
- return writer.getContents();
+ debugLogger.log(`[fromGraph] Reconstructed ${history.length} turns`);
+ return history;
}
diff --git a/packages/core/src/context/graph/mapper.ts b/packages/core/src/context/graph/mapper.ts
index 4e7eef202b..d66928d58f 100644
--- a/packages/core/src/context/graph/mapper.ts
+++ b/packages/core/src/context/graph/mapper.ts
@@ -8,41 +8,20 @@ import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
import type { HistoryEvent } from '../../core/agentChatHistory.js';
import { fromGraph } from './fromGraph.js';
-import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
-import type { NodeBehaviorRegistry } from './behaviorRegistry.js';
export class ContextGraphMapper {
private readonly nodeIdentityMap = new WeakMap