Merge branch 'main' into fix/memory-leaks

This commit is contained in:
Spencer
2026-04-08 16:41:53 -04:00
committed by GitHub
24 changed files with 1785 additions and 390 deletions
-1
View File
@@ -2,7 +2,6 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": false,
"topicUpdateNarration": true
},
"general": {
+14 -14
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.8",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -10070,9 +10070,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.7.tgz",
"integrity": "sha512-bDzQLpLzK/dn9Ur/Ku88ZZR9totVcMGrGYAgPHidsAAbe9NKztU1fggj/iu0wRp5g1kBeALb3cfagFGdDxAU1w==",
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.8.tgz",
"integrity": "sha512-099iGdvWVIM2ivc3NEWyMF7FT06aLmrx1gMGI02ZYB4wLIFn0v/KQl6+20xEwcM6gyzj8Y8842Sf0UH2z0oTDw==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -17421,7 +17421,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17536,7 +17536,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -17558,7 +17558,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.8",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -17708,7 +17708,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17976,7 +17976,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17991,7 +17991,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18008,7 +18008,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18026,7 +18026,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -71,7 +71,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.8",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -139,7 +139,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.8",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.8",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+38
View File
@@ -6,6 +6,7 @@
import {
addMemory,
listInboxSkills,
listMemoryFiles,
refreshMemory,
showMemory,
@@ -30,6 +31,7 @@ export class MemoryCommand implements Command {
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
new InboxMemoryCommand(),
];
readonly requiresWorkspace = true;
@@ -122,3 +124,39 @@ 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.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
if (!context.agentContext.config.isMemoryManagerEnabled()) {
return {
name: this.name,
data: 'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
};
}
const skills = await listInboxSkills(context.agentContext.config);
if (skills.length === 0) {
return { name: this.name, data: 'No extracted skills in inbox.' };
}
const lines = skills.map((s) => {
const date = s.extractedAt
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
: '';
return `- **${s.name}**: ${s.description}${date}`;
});
return {
name: this.name,
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
};
}
}
@@ -457,4 +457,78 @@ describe('memoryCommand', () => {
);
});
});
describe('/memory inbox', () => {
let inboxCommand: SlashCommand;
beforeEach(() => {
inboxCommand = memoryCommand.subCommands!.find(
(cmd) => cmd.name === 'inbox',
)!;
expect(inboxCommand).toBeDefined();
});
it('should return custom_dialog when config is available and flag is enabled', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const mockConfig = {
reloadSkills: vi.fn(),
isMemoryManagerEnabled: vi.fn().mockReturnValue(true),
};
const context = createMockCommandContext({
services: {
agentContext: { config: mockConfig },
},
ui: {
removeComponent: vi.fn(),
reloadCommands: vi.fn(),
},
});
const result = inboxCommand.action(context, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
it('should return info message when memory manager is disabled', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const mockConfig = {
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
};
const context = createMockCommandContext({
services: {
agentContext: { config: mockConfig },
},
});
const result = inboxCommand.action(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
});
});
it('should return error when config is not loaded', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const context = createMockCommandContext({
services: {
agentContext: null,
},
});
const result = inboxCommand.action(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
});
});
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import {
addMemory,
listMemoryFiles,
@@ -13,9 +14,11 @@ import {
import { MessageType } from '../types.js';
import {
CommandKind,
type OpenCustomDialogActionReturn,
type SlashCommand,
type SlashCommandActionReturn,
} from './types.js';
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -124,5 +127,45 @@ export const memoryCommand: SlashCommand = {
);
},
},
{
name: 'inbox',
description:
'Review skills extracted from past sessions and move them to global or project skills',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (
context,
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
const config = context.services.agentContext?.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config.isMemoryManagerEnabled()) {
return {
type: 'message',
messageType: 'info',
content:
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
};
}
return {
type: 'custom_dialog',
component: React.createElement(SkillInboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
}),
};
},
},
],
};
@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill } from '@google/gemini-cli-core';
import {
dismissInboxSkill,
listInboxSkills,
moveInboxSkill,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { SkillInboxDialog } from './SkillInboxDialog.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
dismissInboxSkill: vi.fn(),
listInboxSkills: vi.fn(),
moveInboxSkill: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
),
};
});
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const inboxSkill: InboxSkill = {
dirName: 'inbox-skill',
name: 'Inbox Skill',
description: 'A test skill',
extractedAt: '2025-01-15T10:00:00Z',
};
describe('SkillInboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
});
mockDismissInboxSkill.mockResolvedValue({
success: true,
message: 'Dismissed "inbox-skill" from inbox.',
});
});
it('disables the project destination when the workspace is untrusted', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
} as unknown as Config;
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Project');
expect(frame).toContain('unavailable until this workspace is trusted');
});
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
});
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
expect(onReloadSkills).not.toHaveBeenCalled();
unmount();
});
it('shows inline feedback when moving a skill throws', async () => {
mockMoveInboxSkill.mockRejectedValue(new Error('permission denied'));
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Move "Inbox Skill"');
expect(frame).toContain('Failed to install skill: permission denied');
});
unmount();
});
it('shows inline feedback when reloading skills fails after a move', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const onReloadSkills = vi
.fn()
.mockRejectedValue(new Error('reload hook failed'));
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain(
'Moved "inbox-skill" to ~/.gemini/skills. Failed to reload skills: reload hook failed',
);
});
expect(onReloadSkills).toHaveBeenCalledTimes(1);
unmount();
});
});
@@ -0,0 +1,378 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, Text } 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 {
type Config,
type InboxSkill,
type InboxSkillDestination,
getErrorMessage,
listInboxSkills,
moveInboxSkill,
dismissInboxSkill,
} from '@google/gemini-cli-core';
type Phase = 'list' | 'action';
interface DestinationChoice {
destination: InboxSkillDestination | 'dismiss';
label: string;
description: string;
}
const 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',
},
{
destination: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox',
},
];
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<void>;
}
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
config,
onClose,
onReloadSkills,
}) => {
const keyMatchers = useKeyMatchers();
const isTrustedFolder = config.isTrustedFolder();
const [phase, setPhase] = useState<Phase>('list');
const [skills, setSkills] = useState<InboxSkill[]>([]);
const [loading, setLoading] = useState(true);
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
const [feedback, setFeedback] = useState<{
text: string;
isError: boolean;
} | null>(null);
// Load inbox skills on mount
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const result = await listInboxSkills(config);
if (!cancelled) {
setSkills(result);
setLoading(false);
}
} catch {
if (!cancelled) {
setSkills([]);
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [config]);
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
() =>
skills.map((skill) => ({
key: skill.dirName,
value: skill,
})),
[skills],
);
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
() =>
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 handleSelectSkill = useCallback((skill: InboxSkill) => {
setSelectedSkill(skill);
setFeedback(null);
setPhase('action');
}, []);
const handleSelectDestination = useCallback(
(choice: DestinationChoice) => {
if (!selectedSkill) return;
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 {
let result: { success: boolean; message: string };
if (choice.destination === 'dismiss') {
result = await dismissInboxSkill(config, selectedSkill.dirName);
} else {
result = await moveInboxSkill(
config,
selectedSkill.dirName,
choice.destination,
);
}
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
// Remove the skill from the local list.
setSkills((prev) =>
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
);
setSelectedSkill(null);
setPhase('list');
if (choice.destination === 'dismiss') {
return;
}
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
} catch (error) {
const operation =
choice.destination === 'dismiss'
? 'dismiss skill'
: 'install skill';
setFeedback({
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedSkill, onReloadSkills],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
if (phase === 'action') {
setPhase('list');
setSelectedSkill(null);
setFeedback(null);
} else {
onClose();
}
return true;
}
return false;
},
{ isActive: true, priority: true },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text>Loading inbox</Text>
</Box>
);
}
if (skills.length === 0 && !feedback) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text bold>Skill Inbox</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
No extracted skills in inbox.
</Text>
</Box>
<DialogFooter primaryAction="Esc to close" cancelAction="" />
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
width="100%"
>
{phase === 'list' ? (
<>
<Text bold>
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
</Text>
<Text color={theme.text.secondary}>
Skills extracted from past sessions. Select one to move or dismiss.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<InboxSkill>
items={skillItems}
onSelect={handleSelectSkill}
isFocused={true}
showNumbers={true}
showScrollArrows={true}
maxItemsToShow={8}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{item.value.description}
</Text>
{item.value.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(item.value.extractedAt)}
</Text>
)}
</Box>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to select"
cancelAction="Esc to close"
/>
</>
) : (
<>
<Text bold>Move &quot;{selectedSkill?.name}&quot;</Text>
<Text color={theme.text.secondary}>
Choose where to install this skill.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<DestinationChoice>
items={destinationItems}
onSelect={handleSelectDestination}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
</Box>
);
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+326
View File
@@ -4,11 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
addMemory,
dismissInboxSkill,
listInboxSkills,
listMemoryFiles,
moveInboxSkill,
refreshMemory,
showMemory,
} from './memory.js';
@@ -18,6 +25,12 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
refreshServerHierarchicalMemory: vi.fn(),
}));
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn(),
},
}));
const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory);
describe('memory commands', () => {
@@ -202,4 +215,317 @@ describe('memory commands', () => {
}
});
});
describe('listInboxSkills', () => {
let tmpDir: string;
let skillsDir: string;
let memoryTempDir: string;
let inboxConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
memoryTempDir = path.join(tmpDir, 'memory-temp');
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(memoryTempDir, { recursive: true });
inboxConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectMemoryTempDir: () => memoryTempDir,
getProjectSkillsDir: () => path.join(tmpDir, 'project-skills'),
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should return inbox skills with name, description, and extractedAt', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
await writeSkillMd('other-skill', 'other-skill', 'Another skill');
const stateContent = JSON.stringify({
runs: [
{
runAt: '2025-01-15T10:00:00Z',
sessionIds: ['sess-1'],
skillsCreated: ['my-skill'],
},
{
runAt: '2025-01-16T12:00:00Z',
sessionIds: ['sess-2'],
skillsCreated: ['other-skill'],
},
],
});
await fs.writeFile(
path.join(memoryTempDir, '.extraction-state.json'),
stateContent,
);
const skills = await listInboxSkills(inboxConfig);
expect(skills).toHaveLength(2);
const mySkill = skills.find((s) => s.dirName === 'my-skill');
expect(mySkill).toBeDefined();
expect(mySkill!.name).toBe('my-skill');
expect(mySkill!.description).toBe('A test skill');
expect(mySkill!.extractedAt).toBe('2025-01-15T10:00:00Z');
const otherSkill = skills.find((s) => s.dirName === 'other-skill');
expect(otherSkill).toBeDefined();
expect(otherSkill!.name).toBe('other-skill');
expect(otherSkill!.description).toBe('Another skill');
expect(otherSkill!.extractedAt).toBe('2025-01-16T12:00:00Z');
});
it('should return an empty array when the inbox is empty', async () => {
const skills = await listInboxSkills(inboxConfig);
expect(skills).toEqual([]);
});
it('should return an empty array when the inbox directory does not exist', async () => {
const missingConfig = {
storage: {
getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
getProjectMemoryTempDir: () => memoryTempDir,
},
} as unknown as Config;
const skills = await listInboxSkills(missingConfig);
expect(skills).toEqual([]);
});
});
describe('moveInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
let globalSkillsDir: string;
let projectSkillsDir: string;
let moveConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'move-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
globalSkillsDir = path.join(tmpDir, 'global-skills');
projectSkillsDir = path.join(tmpDir, 'project-skills');
await fs.mkdir(skillsDir, { recursive: true });
moveConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectSkillsDir: () => projectSkillsDir,
},
} as unknown as Config;
vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should move a skill to global skills directory', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');
expect(result.success).toBe(true);
expect(result.message).toBe('Moved "my-skill" to ~/.gemini/skills.');
// Verify the skill was copied to global
const targetSkill = await fs.readFile(
path.join(globalSkillsDir, 'my-skill', 'SKILL.md'),
'utf-8',
);
expect(targetSkill).toContain('name: my-skill');
// Verify the skill was removed from inbox
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should move a skill to project skills directory', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'project');
expect(result.success).toBe(true);
expect(result.message).toBe('Moved "my-skill" to .gemini/skills.');
// Verify the skill was copied to project
const targetSkill = await fs.readFile(
path.join(projectSkillsDir, 'my-skill', 'SKILL.md'),
'utf-8',
);
expect(targetSkill).toContain('name: my-skill');
// Verify the skill was removed from inbox
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should return an error when the source skill does not exist', async () => {
const result = await moveInboxSkill(moveConfig, 'nonexistent', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
});
it('should reject invalid skill directory names', async () => {
const result = await moveInboxSkill(moveConfig, '../escape', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid skill name.');
});
it('should return an error when the target already exists', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
// Pre-create the target
const targetDir = path.join(globalSkillsDir, 'my-skill');
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, 'SKILL.md'), 'existing content');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe(
'A skill named "my-skill" already exists in global skills.',
);
});
it('should detect conflicts based on the normalized skill name', async () => {
await writeSkillMd(
'inbox-skill',
'gke:prs-troubleshooter',
'A test skill',
);
await fs.mkdir(
path.join(globalSkillsDir, 'existing-gke-prs-troubleshooter'),
{ recursive: true },
);
await fs.writeFile(
path.join(
globalSkillsDir,
'existing-gke-prs-troubleshooter',
'SKILL.md',
),
[
'---',
'name: gke-prs-troubleshooter',
'description: Existing skill',
'---',
'Existing body content',
'',
].join('\n'),
);
const result = await moveInboxSkill(moveConfig, 'inbox-skill', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe(
'A skill named "gke-prs-troubleshooter" already exists in global skills.',
);
await expect(
fs.access(path.join(skillsDir, 'inbox-skill', 'SKILL.md')),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(globalSkillsDir, 'inbox-skill')),
).rejects.toThrow();
});
});
describe('dismissInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
let dismissConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dismiss-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
await fs.mkdir(skillsDir, { recursive: true });
dismissConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should remove a skill from the inbox', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await dismissInboxSkill(dismissConfig, 'my-skill');
expect(result.success).toBe(true);
expect(result.message).toBe('Dismissed "my-skill" from inbox.');
// Verify the skill directory was removed
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should return an error when the skill does not exist', async () => {
const result = await dismissInboxSkill(dismissConfig, 'nonexistent');
expect(result.success).toBe(false);
expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
});
it('should reject invalid skill directory names', async () => {
const result = await dismissInboxSkill(dismissConfig, 'nested\\skill');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid skill name.');
});
});
});
+188
View File
@@ -4,8 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
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 { readExtractionState } from '../services/memoryService.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';
@@ -95,3 +100,186 @@ export function listMemoryFiles(config: Config): MessageActionReturn {
content,
};
}
/**
* Represents a skill found in the extraction inbox.
*/
export interface InboxSkill {
/** Directory name in the inbox. */
dirName: string;
/** Skill name from SKILL.md frontmatter. */
name: string;
/** Skill description from SKILL.md frontmatter. */
description: string;
/** When the skill was extracted (ISO string), if known. */
extractedAt?: string;
}
/**
* Scans the skill extraction inbox and returns structured data
* for each extracted skill.
*/
export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
const skillsDir = config.storage.getProjectSkillsMemoryDir();
let entries: Array<import('node:fs').Dirent>;
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch {
return [];
}
const dirs = entries.filter((e) => e.isDirectory());
if (dirs.length === 0) {
return [];
}
// Load extraction state to get dates
const memoryDir = config.storage.getProjectMemoryTempDir();
const statePath = path.join(memoryDir, '.extraction-state.json');
const state = await readExtractionState(statePath);
// Build a map: skillDirName → extractedAt
const skillDateMap = new Map<string, string>();
for (const run of state.runs) {
for (const skillName of run.skillsCreated) {
skillDateMap.set(skillName, run.runAt);
}
}
const skills: InboxSkill[] = [];
for (const dir of dirs) {
const skillPath = path.join(skillsDir, dir.name, 'SKILL.md');
const skillDef = await loadSkillFromFile(skillPath);
if (!skillDef) continue;
skills.push({
dirName: dir.name,
name: skillDef.name,
description: skillDef.description,
extractedAt: skillDateMap.get(dir.name),
});
}
return skills;
}
export type InboxSkillDestination = 'global' | 'project';
function isValidInboxSkillDirName(dirName: string): boolean {
return (
dirName.length > 0 &&
dirName !== '.' &&
dirName !== '..' &&
!dirName.includes('/') &&
!dirName.includes('\\')
);
}
async function getSkillNameForConflictCheck(
skillDir: string,
fallbackName: string,
): Promise<string> {
const skill = await loadSkillFromFile(path.join(skillDir, 'SKILL.md'));
return skill?.name ?? fallbackName;
}
/**
* Copies an inbox skill to the target skills directory.
*/
export async function moveInboxSkill(
config: Config,
dirName: string,
destination: InboxSkillDestination,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxSkillDirName(dirName)) {
return {
success: false,
message: 'Invalid skill name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const sourcePath = path.join(skillsDir, dirName);
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Skill "${dirName}" not found in inbox.`,
};
}
const targetBase =
destination === 'global'
? Storage.getUserSkillsDir()
: config.storage.getProjectSkillsDir();
const targetPath = path.join(targetBase, dirName);
const skillName = await getSkillNameForConflictCheck(sourcePath, dirName);
try {
await fs.access(targetPath);
return {
success: false,
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
};
} catch {
// Target doesn't exist — good
}
const existingTargetSkills = await loadSkillsFromDir(targetBase);
if (existingTargetSkills.some((skill) => skill.name === skillName)) {
return {
success: false,
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
};
}
await fs.mkdir(targetBase, { recursive: true });
await fs.cp(sourcePath, targetPath, { recursive: true });
// Remove from inbox after successful copy
await fs.rm(sourcePath, { recursive: true, force: true });
const label =
destination === 'global' ? '~/.gemini/skills' : '.gemini/skills';
return {
success: true,
message: `Moved "${dirName}" to ${label}.`,
};
}
/**
* Removes a skill from the extraction inbox.
*/
export async function dismissInboxSkill(
config: Config,
dirName: string,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxSkillDirName(dirName)) {
return {
success: false,
message: 'Invalid skill name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const sourcePath = path.join(skillsDir, dirName);
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Skill "${dirName}" not found in inbox.`,
};
}
await fs.rm(sourcePath, { recursive: true, force: true });
return {
success: true,
message: `Dismissed "${dirName}" from inbox.`,
};
}
@@ -13,6 +13,7 @@ import {
type ConversationRecord,
} from './chatRecordingService.js';
import type { ExtractionState, ExtractionRun } from './memoryService.js';
import { coreEvents } from '../utils/events.js';
// Mock external modules used by startMemoryService
vi.mock('../agents/local-executor.js', () => ({
@@ -29,6 +30,7 @@ vi.mock('../agents/skill-extraction-agent.js', () => ({
promptConfig: { systemPrompt: 'test' },
tools: [],
outputSchema: {},
modelConfig: { model: 'test-model' },
}),
}));
@@ -51,6 +53,33 @@ vi.mock('../resources/resource-registry.js', () => ({
ResourceRegistry: vi.fn(),
}));
vi.mock('../policy/policy-engine.js', () => ({
PolicyEngine: vi.fn(),
}));
vi.mock('../policy/types.js', () => ({
PolicyDecision: { ALLOW: 'ALLOW' },
}));
vi.mock('../confirmation-bus/message-bus.js', () => ({
MessageBus: vi.fn(),
}));
vi.mock('../agents/registry.js', () => ({
getModelConfigAlias: vi.fn().mockReturnValue('skill-extraction-config'),
}));
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/tmp/fake-user-skills'),
},
}));
vi.mock('../skills/skillLoader.js', () => ({
FRONTMATTER_REGEX: /^---\n([\s\S]*?)\n---/,
parseFrontmatter: vi.fn().mockReturnValue(null),
}));
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: {
debug: vi.fn(),
@@ -59,6 +88,12 @@ vi.mock('../utils/debugLogger.js', () => ({
},
}));
vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
},
}));
// Helper to create a minimal ConversationRecord
function createConversation(
overrides: Partial<ConversationRecord> & { messageCount?: number } = {},
@@ -427,6 +462,77 @@ describe('memoryService', () => {
}),
);
});
it('emits feedback when new skills are created during extraction', async () => {
const { startMemoryService } = await import('./memoryService.js');
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
// Reset mocks that may carry state from prior tests
vi.mocked(coreEvents.emitFeedback).mockClear();
vi.mocked(LocalAgentExecutor.create).mockReset();
const memoryDir = path.join(tmpDir, 'memory4');
const skillsDir = path.join(tmpDir, 'skills4');
const projectTempDir = path.join(tmpDir, 'temp4');
const chatsDir = path.join(projectTempDir, 'chats');
await fs.mkdir(memoryDir, { recursive: true });
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(chatsDir, { recursive: true });
// Write a valid session with enough messages to pass the filter
const conversation = createConversation({
sessionId: 'skill-session',
messageCount: 20,
});
await fs.writeFile(
path.join(chatsDir, 'session-2025-01-01T00-00-skill001.json'),
JSON.stringify(conversation),
);
// Override LocalAgentExecutor.create to return an executor whose run
// creates a new skill directory with a SKILL.md in the skillsDir
vi.mocked(LocalAgentExecutor.create).mockResolvedValueOnce({
run: vi.fn().mockImplementation(async () => {
const newSkillDir = path.join(skillsDir, 'my-new-skill');
await fs.mkdir(newSkillDir, { recursive: true });
await fs.writeFile(
path.join(newSkillDir, 'SKILL.md'),
'# My New Skill',
);
return undefined;
}),
} as never);
const mockConfig = {
storage: {
getProjectMemoryDir: vi.fn().mockReturnValue(memoryDir),
getProjectMemoryTempDir: vi.fn().mockReturnValue(memoryDir),
getProjectSkillsMemoryDir: vi.fn().mockReturnValue(skillsDir),
getProjectTempDir: vi.fn().mockReturnValue(projectTempDir),
},
getToolRegistry: vi.fn(),
getMessageBus: vi.fn(),
getGeminiClient: vi.fn(),
getSkillManager: vi.fn().mockReturnValue({ getSkills: () => [] }),
modelConfigService: {
registerRuntimeModelConfig: vi.fn(),
},
sandboxManager: undefined,
} as unknown as Parameters<typeof startMemoryService>[0];
await startMemoryService(mockConfig);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
expect.stringContaining('my-new-skill'),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
expect.stringContaining('/memory inbox'),
);
});
});
describe('getProcessedSessionIds', () => {
@@ -14,6 +14,7 @@ import {
type ConversationRecord,
} from './chatRecordingService.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
import { isNodeError } from '../utils/errors.js';
import { FRONTMATTER_REGEX, parseFrontmatter } from '../skills/skillLoader.js';
import { LocalAgentExecutor } from '../agents/local-executor.js';
@@ -640,6 +641,11 @@ export async function startMemoryService(config: Config): Promise<void> {
debugLogger.log(
`[MemoryService] Completed in ${elapsed}s. Created ${skillsCreated.length} skill(s): ${skillsCreated.join(', ')}`,
);
const skillList = skillsCreated.join(', ');
coreEvents.emitFeedback(
'info',
`${skillsCreated.length} new skill${skillsCreated.length > 1 ? 's' : ''} extracted from past sessions: ${skillList}. Use /memory inbox to review.`,
);
} else {
debugLogger.log(
`[MemoryService] Completed in ${elapsed}s. No new skills created (processed ${newSessionIds.length} session(s))`,
@@ -59,52 +59,56 @@ export class SandboxedFileSystemService implements FileSystemService {
},
});
return new Promise((resolve, reject) => {
// Direct spawn is necessary here for streaming large file contents.
try {
return await new Promise((resolve, reject) => {
// Direct spawn is necessary here for streaming large file contents.
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
let output = '';
let error = '';
let output = '';
let error = '';
child.stdout?.on('data', (data) => {
output += data.toString();
});
child.stdout?.on('data', (data) => {
output += data.toString();
});
child.stderr?.on('data', (data) => {
error += data.toString();
});
child.stderr?.on('data', (data) => {
error += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
const isEnoent =
error.toLowerCase().includes('no such file or directory') ||
error.toLowerCase().includes('enoent') ||
error.toLowerCase().includes('could not find file') ||
error.toLowerCase().includes('could not find a part of the path');
const err = new Error(
`Sandbox Error: read_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`,
);
if (isEnoent) {
Object.assign(err, { code: 'ENOENT' });
child.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
const isEnoent =
error.toLowerCase().includes('no such file or directory') ||
error.toLowerCase().includes('enoent') ||
error.toLowerCase().includes('could not find file') ||
error.toLowerCase().includes('could not find a part of the path');
const err = new Error(
`Sandbox Error: read_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`,
);
if (isEnoent) {
Object.assign(err, { code: 'ENOENT' });
}
reject(err);
}
reject(err);
}
});
});
child.on('error', (err) => {
reject(
new Error(
`Sandbox Error: Failed to spawn read_file for '${filePath}': ${err.message}`,
),
);
child.on('error', (err) => {
reject(
new Error(
`Sandbox Error: Failed to spawn read_file for '${filePath}': ${err.message}`,
),
);
});
});
});
} finally {
prepared.cleanup?.();
}
}
async writeTextFile(filePath: string, content: string): Promise<void> {
@@ -124,53 +128,57 @@ export class SandboxedFileSystemService implements FileSystemService {
},
});
return new Promise((resolve, reject) => {
// Direct spawn is necessary here for streaming large file contents.
try {
return await new Promise((resolve, reject) => {
// Direct spawn is necessary here for streaming large file contents.
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
child.stdin?.on('error', (err) => {
// Silently ignore EPIPE errors on stdin, they will be caught by the process error/close listeners
if (isNodeError(err) && err.code === 'EPIPE') {
return;
}
debugLogger.error(
`Sandbox Error: stdin error for '${filePath}': ${
err instanceof Error ? err.message : String(err)
}`,
);
});
child.stdin?.on('error', (err) => {
// Silently ignore EPIPE errors on stdin, they will be caught by the process error/close listeners
if (isNodeError(err) && err.code === 'EPIPE') {
return;
}
debugLogger.error(
`Sandbox Error: stdin error for '${filePath}': ${
err instanceof Error ? err.message : String(err)
}`,
);
});
child.stdin?.write(content);
child.stdin?.end();
child.stdin?.write(content);
child.stdin?.end();
let error = '';
child.stderr?.on('data', (data) => {
error += data.toString();
});
let error = '';
child.stderr?.on('data', (data) => {
error += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`Sandbox Error: write_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`,
),
);
}
});
child.on('error', (err) => {
reject(
new Error(
`Sandbox Error: write_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`,
`Sandbox Error: Failed to spawn write_file for '${filePath}': ${err.message}`,
),
);
}
});
});
child.on('error', (err) => {
reject(
new Error(
`Sandbox Error: Failed to spawn write_file for '${filePath}': ${err.message}`,
),
);
});
});
} finally {
prepared.cleanup?.();
}
}
}
@@ -510,21 +510,24 @@ export class ShellExecutionService {
shellExecutionConfig: ShellExecutionConfig,
isInteractive: boolean,
): Promise<ShellExecutionHandle> {
let cmdCleanup: (() => void) | undefined;
try {
const isWindows = os.platform() === 'win32';
const prepared = await this.prepareExecution(
commandToExecute,
cwd,
shellExecutionConfig,
isInteractive,
);
cmdCleanup = prepared.cleanup;
const {
program: finalExecutable,
args: finalArgs,
env: finalEnv,
cwd: finalCwd,
cleanup: cmdCleanup,
} = await this.prepareExecution(
commandToExecute,
cwd,
shellExecutionConfig,
isInteractive,
);
} = prepared;
const child = cpSpawn(finalExecutable, finalArgs, {
cwd: finalCwd,
@@ -811,6 +814,7 @@ export class ShellExecutionService {
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
cmdCleanup?.();
return {
pid: undefined,
result: Promise.resolve({
@@ -826,7 +830,6 @@ export class ShellExecutionService {
};
}
}
private static async executeWithPty(
commandToExecute: string,
cwd: string,
@@ -840,23 +843,26 @@ export class ShellExecutionService {
throw new Error('PTY implementation not found');
}
let spawnedPty: IPty | undefined;
let cmdCleanup: (() => void) | undefined;
try {
const cols = shellExecutionConfig.terminalWidth ?? 80;
const rows = shellExecutionConfig.terminalHeight ?? 30;
const prepared = await this.prepareExecution(
commandToExecute,
cwd,
shellExecutionConfig,
true,
);
cmdCleanup = prepared.cleanup;
const {
program: finalExecutable,
args: finalArgs,
env: finalEnv,
cwd: finalCwd,
cleanup: cmdCleanup,
} = await this.prepareExecution(
commandToExecute,
cwd,
shellExecutionConfig,
true,
);
} = prepared;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, {
@@ -1237,6 +1243,7 @@ export class ShellExecutionService {
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
cmdCleanup?.();
if (spawnedPty) {
try {
@@ -1270,7 +1277,6 @@ export class ShellExecutionService {
}
}
}
/**
* Writes a string to the pseudo-terminal (PTY) of a running process.
*
+22 -14
View File
@@ -326,6 +326,7 @@ class GrepToolInvocation extends BaseToolInvocation<
let finalCommand = checkCommand;
let finalArgs = checkArgs;
let finalEnv = process.env;
let cleanup: (() => void) | undefined;
if (sandboxManager) {
try {
@@ -338,6 +339,7 @@ class GrepToolInvocation extends BaseToolInvocation<
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanup = prepared.cleanup;
} catch (err) {
debugLogger.debug(
`[GrepTool] Sandbox preparation failed for '${command}':`,
@@ -346,21 +348,27 @@ class GrepToolInvocation extends BaseToolInvocation<
}
}
return await new Promise((resolve) => {
const child = spawn(finalCommand, finalArgs, {
stdio: 'ignore',
shell: true,
env: finalEnv,
try {
return await new Promise((resolve) => {
const child = spawn(finalCommand, finalArgs, {
stdio: 'ignore',
shell: true,
env: finalEnv,
});
child.on('close', (code) => {
resolve(code === 0);
});
child.on('error', (err) => {
debugLogger.debug(
`[GrepTool] Failed to start process for '${command}':`,
err.message,
);
resolve(false);
});
});
child.on('close', (code) => resolve(code === 0));
child.on('error', (err) => {
debugLogger.debug(
`[GrepTool] Failed to start process for '${command}':`,
err.message,
);
resolve(false);
});
});
} finally {
cleanup?.();
}
} catch {
return false;
}
+158 -142
View File
@@ -65,6 +65,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
let finalCommand = callCommand;
let finalArgs = args;
let finalEnv = process.env;
let cleanupFunc: (() => void) | undefined;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
@@ -77,58 +78,63 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanupFunc = prepared.cleanup;
}
const child = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();
let stdout = '';
let stderr = '';
let error: Error | null = null;
let code: number | null = null;
let signal: NodeJS.Signals | null = null;
await new Promise<void>((resolve) => {
const onStdout = (data: Buffer) => {
stdout += data?.toString();
};
try {
const child = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();
const onStderr = (data: Buffer) => {
stderr += data?.toString();
};
await new Promise<void>((resolve) => {
const onStdout = (data: Buffer) => {
stdout += data?.toString();
};
const onError = (err: Error) => {
error = err;
};
const onStderr = (data: Buffer) => {
stderr += data?.toString();
};
const onClose = (
_code: number | null,
_signal: NodeJS.Signals | null,
) => {
code = _code;
signal = _signal;
cleanup();
resolve();
};
const onError = (err: Error) => {
error = err;
};
const cleanup = () => {
child.stdout.removeListener('data', onStdout);
child.stderr.removeListener('data', onStderr);
child.removeListener('error', onError);
child.removeListener('close', onClose);
if (child.connected) {
child.disconnect();
}
};
const onClose = (
_code: number | null,
_signal: NodeJS.Signals | null,
) => {
code = _code;
signal = _signal;
cleanup();
resolve();
};
child.stdout.on('data', onStdout);
child.stderr.on('data', onStderr);
child.on('error', onError);
child.on('close', onClose);
});
const cleanup = () => {
child.stdout.removeListener('data', onStdout);
child.stderr.removeListener('data', onStderr);
child.removeListener('error', onError);
child.removeListener('close', onClose);
if (child.connected) {
child.disconnect();
}
};
child.stdout.on('data', onStdout);
child.stderr.on('data', onStderr);
child.on('error', onError);
child.on('close', onClose);
});
} finally {
cleanupFunc?.();
}
// if there is any error, non-zero exit code, signal, or stderr, return error details instead of stdout
if (error || code !== 0 || signal || stderr) {
@@ -374,6 +380,7 @@ export class ToolRegistry {
.slice(1)
.filter((p): p is string => typeof p === 'string');
let finalEnv = process.env;
let cleanupFunc: (() => void) | undefined;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
@@ -386,118 +393,127 @@ export class ToolRegistry {
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanupFunc = prepared.cleanup;
}
const proc = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
const stderrDecoder = new StringDecoder('utf8');
let sizeLimitExceeded = false;
const MAX_STDOUT_SIZE = 10 * 1024 * 1024; // 10MB limit
const MAX_STDERR_SIZE = 10 * 1024 * 1024; // 10MB limit
let stdoutByteLength = 0;
let stderrByteLength = 0;
proc.stdout.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stdoutByteLength + data.length > MAX_STDOUT_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stdoutByteLength += data.length;
stdout += stdoutDecoder.write(data);
});
proc.stderr.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stderrByteLength + data.length > MAX_STDERR_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stderrByteLength += data.length;
stderr += stderrDecoder.write(data);
});
await new Promise<void>((resolve, reject) => {
proc.on('error', reject);
proc.on('close', (code) => {
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (sizeLimitExceeded) {
return reject(
new Error(
`Tool discovery command output exceeded size limit of ${MAX_STDOUT_SIZE} bytes.`,
),
);
}
if (code !== 0) {
coreEvents.emitFeedback(
'error',
`Tool discovery command failed with code ${code}.`,
stderr,
);
return reject(
new Error(`Tool discovery command failed with exit code ${code}`),
);
}
resolve();
try {
const proc = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
const stderrDecoder = new StringDecoder('utf8');
let sizeLimitExceeded = false;
const MAX_STDOUT_SIZE = 10 * 1024 * 1024; // 10MB limit
const MAX_STDERR_SIZE = 10 * 1024 * 1024; // 10MB limit
// execute discovery command and extract function declarations (w/ or w/o "tool" wrappers)
const functions: FunctionDeclaration[] = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const discoveredItems = JSON.parse(stdout.trim());
let stdoutByteLength = 0;
let stderrByteLength = 0;
if (!discoveredItems || !Array.isArray(discoveredItems)) {
throw new Error(
'Tool discovery command did not return a JSON array of tools.',
);
}
proc.stdout.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stdoutByteLength + data.length > MAX_STDOUT_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stdoutByteLength += data.length;
stdout += stdoutDecoder.write(data);
});
for (const tool of discoveredItems) {
if (tool && typeof tool === 'object') {
if (Array.isArray(tool['function_declarations'])) {
functions.push(...tool['function_declarations']);
} else if (Array.isArray(tool['functionDeclarations'])) {
functions.push(...tool['functionDeclarations']);
} else if (tool['name']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functions.push(tool as FunctionDeclaration);
proc.stderr.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stderrByteLength + data.length > MAX_STDERR_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stderrByteLength += data.length;
stderr += stderrDecoder.write(data);
});
await new Promise<void>((resolve, reject) => {
proc.on('error', (err) => {
reject(err);
});
proc.on('close', (code) => {
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (sizeLimitExceeded) {
return reject(
new Error(
`Tool discovery command output exceeded size limit of ${MAX_STDOUT_SIZE} bytes.`,
),
);
}
if (code !== 0) {
coreEvents.emitFeedback(
'error',
`Tool discovery command failed with code ${code}.`,
stderr,
);
return reject(
new Error(
`Tool discovery command failed with exit code ${code}`,
),
);
}
resolve();
});
});
// execute discovery command and extract function declarations (w/ or w/o "tool" wrappers)
const functions: FunctionDeclaration[] = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const discoveredItems = JSON.parse(stdout.trim());
if (!discoveredItems || !Array.isArray(discoveredItems)) {
throw new Error(
'Tool discovery command did not return a JSON array of tools.',
);
}
for (const tool of discoveredItems) {
if (tool && typeof tool === 'object') {
if (Array.isArray(tool['function_declarations'])) {
functions.push(...tool['function_declarations']);
} else if (Array.isArray(tool['functionDeclarations'])) {
functions.push(...tool['functionDeclarations']);
} else if (tool['name']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functions.push(tool as FunctionDeclaration);
}
}
}
}
// register each function as a tool
for (const func of functions) {
if (!func.name) {
debugLogger.warn('Discovered a tool with no name. Skipping.');
continue;
// register each function as a tool
for (const func of functions) {
if (!func.name) {
debugLogger.warn('Discovered a tool with no name. Skipping.');
continue;
}
const parameters =
func.parametersJsonSchema &&
typeof func.parametersJsonSchema === 'object' &&
!Array.isArray(func.parametersJsonSchema)
? func.parametersJsonSchema
: {};
this.registerTool(
new DiscoveredTool(
this.config,
func.name,
DISCOVERED_TOOL_PREFIX + func.name,
func.description ?? '',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parameters as Record<string, unknown>,
this.messageBus,
),
);
}
const parameters =
func.parametersJsonSchema &&
typeof func.parametersJsonSchema === 'object' &&
!Array.isArray(func.parametersJsonSchema)
? func.parametersJsonSchema
: {};
this.registerTool(
new DiscoveredTool(
this.config,
func.name,
DISCOVERED_TOOL_PREFIX + func.name,
func.description ?? '',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parameters as Record<string, unknown>,
this.messageBus,
),
);
} finally {
cleanupFunc?.();
}
} catch (e) {
debugLogger.error(`Tool discovery command "${discoveryCmd}" failed:`, e);
+126 -114
View File
@@ -861,34 +861,40 @@ export const spawnAsync = async (
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
return new Promise((resolve, reject) => {
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
});
let stdout = '';
let stderr = '';
try {
return await new Promise((resolve, reject) => {
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
}
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`Command failed with exit code ${code}:\n${stderr}`),
);
}
});
child.on('error', (err) => {
reject(err);
child.on('error', (err) => {
reject(err);
});
});
});
} finally {
prepared.cleanup?.();
}
};
/**
@@ -916,109 +922,115 @@ export async function* execStreaming(
env: options?.env ?? process.env,
});
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
// ensure we don't open a window on windows if possible/relevant
windowsHide: true,
});
const rl = readline.createInterface({
input: child.stdout,
terminal: false,
});
const errorChunks: Buffer[] = [];
let stderrTotalBytes = 0;
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
child.stderr.on('data', (chunk) => {
if (stderrTotalBytes < MAX_STDERR_BYTES) {
errorChunks.push(chunk);
stderrTotalBytes += chunk.length;
}
});
let error: Error | null = null;
child.on('error', (err) => {
error = err;
});
const onAbort = () => {
// If manually aborted by signal, we kill immediately.
if (!child.killed) child.kill();
};
if (options?.signal?.aborted) {
onAbort();
} else {
options?.signal?.addEventListener('abort', onAbort);
}
let finished = false;
try {
for await (const line of rl) {
if (options?.signal?.aborted) break;
yield line;
}
finished = true;
} finally {
rl.close();
options?.signal?.removeEventListener('abort', onAbort);
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
// Ensure process is killed when the generator is closed (consumer breaks loop)
let killedByGenerator = false;
if (!finished && child.exitCode === null && !child.killed) {
try {
child.kill();
} catch {
// ignore error if process is already dead
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
// ensure we don't open a window on windows if possible/relevant
windowsHide: true,
});
const rl = readline.createInterface({
input: child.stdout,
terminal: false,
});
const errorChunks: Buffer[] = [];
let stderrTotalBytes = 0;
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
child.stderr.on('data', (chunk) => {
if (stderrTotalBytes < MAX_STDERR_BYTES) {
errorChunks.push(chunk);
stderrTotalBytes += chunk.length;
}
killedByGenerator = true;
});
let error: Error | null = null;
child.on('error', (err) => {
error = err;
});
const onAbort = () => {
// If manually aborted by signal, we kill immediately.
if (!child.killed) child.kill();
};
if (options?.signal?.aborted) {
onAbort();
} else {
options?.signal?.addEventListener('abort', onAbort);
}
// Ensure we wait for the process to exit to check codes
await new Promise<void>((resolve, reject) => {
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
if (error) {
reject(error);
return;
let finished = false;
try {
for await (const line of rl) {
if (options?.signal?.aborted) break;
yield line;
}
finished = true;
} finally {
rl.close();
options?.signal?.removeEventListener('abort', onAbort);
// Ensure process is killed when the generator is closed (consumer breaks loop)
let killedByGenerator = false;
if (!finished && child.exitCode === null && !child.killed) {
try {
child.kill();
} catch {
// ignore error if process is already dead
}
killedByGenerator = true;
}
function checkExit(code: number | null) {
// If we aborted or killed it manually, we treat it as success (stop waiting)
if (options?.signal?.aborted || killedByGenerator) {
resolve();
// Ensure we wait for the process to exit to check codes
await new Promise<void>((resolve, reject) => {
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
if (error) {
reject(error);
return;
}
const allowed = options?.allowedExitCodes ?? [0];
if (code !== null && allowed.includes(code)) {
resolve();
} else {
// If we have an accumulated error or explicit error event
if (error) reject(error);
else {
const stderr = Buffer.concat(errorChunks).toString('utf8');
const truncatedMsg =
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
reject(
new Error(
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
),
);
function checkExit(code: number | null) {
// If we aborted or killed it manually, we treat it as success (stop waiting)
if (options?.signal?.aborted || killedByGenerator) {
resolve();
return;
}
const allowed = options?.allowedExitCodes ?? [0];
if (code !== null && allowed.includes(code)) {
resolve();
} else {
// If we have an accumulated error or explicit error event
if (error) reject(error);
else {
const stderr = Buffer.concat(errorChunks).toString('utf8');
const truncatedMsg =
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
reject(
new Error(
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
),
);
}
}
}
}
if (child.exitCode !== null) {
checkExit(child.exitCode);
} else {
child.on('close', (code) => checkExit(code));
child.on('error', (err) => reject(err));
}
});
if (child.exitCode !== null) {
checkExit(child.exitCode);
} else {
child.on('close', (code) => checkExit(code));
child.on('error', (err) => {
reject(err);
});
}
});
}
} finally {
prepared.cleanup?.();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {