diff --git a/.gemini/settings.json b/.gemini/settings.json index eb7741997b..6a0121df17 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,7 +2,6 @@ "experimental": { "extensionReloading": true, "modelSteering": true, - "memoryManager": false, "topicUpdateNarration": true }, "general": { diff --git a/package-lock.json b/package-lock.json index 7ec397323e..2d3e670b74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9f67253ccc..f531b41dbc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 5257e56240..51e0450c97 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 52ae182dca..82ff74b08e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index ac919f2a9b..4d704cc8dd 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -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 { + 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')}`, + }; + } +} diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index f02393bef2..c0fdb62ba2 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -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.', + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 145fbae9c3..1cb4f27958 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -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(); + }, + }), + }; + }, + }, ], }; diff --git a/packages/cli/src/ui/components/SkillInboxDialog.test.tsx b/packages/cli/src/ui/components/SkillInboxDialog.test.tsx new file mode 100644 index 0000000000..e3c1aa9c91 --- /dev/null +++ b/packages/cli/src/ui/components/SkillInboxDialog.test.tsx @@ -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(); + + 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( + , + ), + ); + + 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( + , + ), + ); + + 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( + , + ), + ); + + 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(); + }); +}); diff --git a/packages/cli/src/ui/components/SkillInboxDialog.tsx b/packages/cli/src/ui/components/SkillInboxDialog.tsx new file mode 100644 index 0000000000..ff2d75527f --- /dev/null +++ b/packages/cli/src/ui/components/SkillInboxDialog.tsx @@ -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; +} + +export const SkillInboxDialog: React.FC = ({ + config, + onClose, + onReloadSkills, +}) => { + const keyMatchers = useKeyMatchers(); + const isTrustedFolder = config.isTrustedFolder(); + const [phase, setPhase] = useState('list'); + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedSkill, setSelectedSkill] = useState(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> = useMemo( + () => + skills.map((skill) => ({ + key: skill.dirName, + value: skill, + })), + [skills], + ); + + const destinationItems: Array> = 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 ( + + Loading inbox… + + ); + } + + if (skills.length === 0 && !feedback) { + return ( + + Skill Inbox + + + No extracted skills in inbox. + + + + + ); + } + + return ( + + {phase === 'list' ? ( + <> + + Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''}) + + + Skills extracted from past sessions. Select one to move or dismiss. + + + + + items={skillItems} + onSelect={handleSelectSkill} + isFocused={true} + showNumbers={true} + showScrollArrows={true} + maxItemsToShow={8} + renderItem={(item, { titleColor }) => ( + + + {item.value.name} + + + + {item.value.description} + + {item.value.extractedAt && ( + + {' · '} + {formatDate(item.value.extractedAt)} + + )} + + + )} + /> + + + {feedback && ( + + + {feedback.isError ? '✗ ' : '✓ '} + {feedback.text} + + + )} + + + + ) : ( + <> + Move "{selectedSkill?.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} + + + )} + + + + )} + + ); +}; diff --git a/packages/core/package.json b/packages/core/package.json index de105d4389..53619d94c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { diff --git a/packages/core/src/commands/memory.test.ts b/packages/core/src/commands/memory.test.ts index 37ff15052f..113d1b1ec5 100644 --- a/packages/core/src/commands/memory.test.ts +++ b/packages/core/src/commands/memory.test.ts @@ -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 { + 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 { + 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 { + 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.'); + }); + }); }); diff --git a/packages/core/src/commands/memory.ts b/packages/core/src/commands/memory.ts index d8857469bd..fd34601690 100644 --- a/packages/core/src/commands/memory.ts +++ b/packages/core/src/commands/memory.ts @@ -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 { + const skillsDir = config.storage.getProjectSkillsMemoryDir(); + + let entries: Array; + 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(); + 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 { + 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.`, + }; +} diff --git a/packages/core/src/services/memoryService.test.ts b/packages/core/src/services/memoryService.test.ts index 65f1e74f55..b6084b6627 100644 --- a/packages/core/src/services/memoryService.test.ts +++ b/packages/core/src/services/memoryService.test.ts @@ -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 & { 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[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', () => { diff --git a/packages/core/src/services/memoryService.ts b/packages/core/src/services/memoryService.ts index 495cbdc5ef..7b91047dba 100644 --- a/packages/core/src/services/memoryService.ts +++ b/packages/core/src/services/memoryService.ts @@ -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 { 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))`, diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index 03907657f3..d5e6dd4b4a 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -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 { @@ -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?.(); + } } } diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index dfbb3a5033..46b894426f 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -510,21 +510,24 @@ export class ShellExecutionService { shellExecutionConfig: ShellExecutionConfig, isInteractive: boolean, ): Promise { + 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. * diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index ac7dc6cf02..3f6fd08ff3 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -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; } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index f9551d75da..5b174a97d7 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -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((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((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((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((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, + 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, - this.messageBus, - ), - ); + } finally { + cleanupFunc?.(); } } catch (e) { debugLogger.error(`Tool discovery command "${discoveryCmd}" failed:`, e); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index c6150ab4b3..3e69fb4de3 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -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((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((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?.(); } } diff --git a/packages/devtools/package.json b/packages/devtools/package.json index ed3160b7f1..60eba8c1a6 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -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", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7bd9c62d51..225b60ce2d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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": { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index b16497da3c..8a1d11000f 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b2a2912c7e..da5931edd3 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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": {