diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 746fc14475..13efe8f517 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3688,6 +3688,46 @@ describe('loadCliConfig mcpEnabled', () => { expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); }); + it('should use tracker directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-tracker', + isActive: true, + tracker: { directory: 'ext-tracker-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getTrackerDir()).toContain('ext-tracker-dir'); + }); + + it('should NOT use tracker directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { tracker: { directory: 'user-tracker-dir' } }, + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-tracker', + isActive: true, + tracker: { directory: 'ext-tracker-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getTrackerDir()).toContain('user-tracker-dir'); + expect(config.storage.getTrackerDir()).not.toContain('ext-tracker-dir'); + }); + it('should NOT use plan directory from inactive extension', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ @@ -3709,6 +3749,27 @@ describe('loadCliConfig mcpEnabled', () => { ); }); + it('should NOT use tracker directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-tracker', + isActive: false, + tracker: { directory: 'ext-tracker-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getTrackerDir()).not.toContain( + 'ext-tracker-dir-inactive', + ); + }); + it('should use default path if neither user nor extension settings provide a plan directory', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 227ad4e8ed..2e797942ba 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -570,9 +570,17 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); - const extensionPlanSettings = extensionManager + const activeExtensions = extensionManager .getExtensions() - .find((ext) => ext.isActive && ext.plan?.directory)?.plan; + .filter((ext) => ext.isActive); + + const extensionPlanSettings = activeExtensions.find( + (ext) => !!ext.plan?.directory, + )?.plan; + + const extensionTrackerSettings = activeExtensions.find( + (ext) => !!ext.tracker?.directory, + )?.tracker; const experimentalJitContext = settings.experimental.jitContext; @@ -931,6 +939,9 @@ export async function loadCliConfig( planSettings: settings.general?.plan?.directory ? settings.general.plan : (extensionPlanSettings ?? settings.general?.plan), + trackerSettings: settings.general?.tracker?.directory + ? settings.general.tracker + : (extensionTrackerSettings ?? settings.general?.tracker), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 90413fd78a..fd8d72399f 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -1050,6 +1050,7 @@ Would you like to attempt to install via "git clone" instead?`, rules, checkers, plan: config.plan, + tracker: config.tracker, }; } catch (e) { const extName = path.basename(extensionDir); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index a29479b4d8..7344fd8546 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -42,6 +42,15 @@ export interface ExtensionConfig { */ directory?: string; }; + /** + * Task tracking configuration contributed by this extension. + */ + tracker?: { + /** + * The directory where task tracking data is stored. + */ + directory?: string; + }; /** * Used to migrate an extension to a new repository source. */ diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 37ddf87642..69b51eae94 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -125,6 +125,16 @@ describe('SettingsSchema', () => { ).toBe('string'); }); + it('should have tracker nested properties', () => { + expect( + getSettingsSchema().general?.properties?.tracker?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.tracker?.properties?.directory + .type, + ).toBe('string'); + }); + it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 97d35953bb..b6665c3fef 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -315,6 +315,27 @@ const SETTINGS_SCHEMA = { }, }, }, + tracker: { + type: 'object', + label: 'Task Tracker', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Task tracking features configuration.', + showInDialog: false, + properties: { + directory: { + type: 'string', + label: 'Tracker Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where task tracking data is stored. If not specified, defaults to the system temporary directory.', + showInDialog: true, + }, + }, + }, retryFetchErrors: { type: 'boolean', label: 'Retry Fetch Errors', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 89b3af04bc..6995ab915c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -184,6 +184,10 @@ export interface PlanSettings { modelRouting?: boolean; } +export interface TrackerSettings { + directory?: string; +} + export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -375,6 +379,10 @@ export interface GeminiCLIExtension { */ directory?: string; }; + /** + * Task tracking configuration contributed by this extension. + */ + tracker?: TrackerSettings; /** * Used to migrate an extension to a new repository source. */ @@ -657,6 +665,7 @@ export interface ConfigParameters { plan?: boolean; tracker?: boolean; planSettings?: PlanSettings; + trackerSettings?: TrackerSettings; worktreeSettings?: WorktreeSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; @@ -1136,6 +1145,7 @@ export class Config implements McpContext, AgentLoopContext { this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this._sessionId); this.storage.setCustomPlansDir(params.planSettings?.directory); + this.storage.setCustomTrackerDir(params.trackerSettings?.directory); this.fakeResponses = params.fakeResponses; this.recordResponses = params.recordResponses; @@ -2541,9 +2551,7 @@ export class Config implements McpContext, AgentLoopContext { getTrackerService(): TrackerService { if (!this.trackerService) { - this.trackerService = new TrackerService( - this.storage.getProjectTempTrackerDir(), - ); + this.trackerService = new TrackerService(this.storage.getTrackerDir()); } return this.trackerService; } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index ea8fce6da3..d49ecb6477 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -371,6 +371,78 @@ describe('Storage – additional helpers', () => { }); }); }); + + describe('getTrackerDir', () => { + interface TestCase { + name: string; + customDir: string | undefined; + expected: string | (() => string); + expectedError?: string; + setup?: () => () => void; + } + + const testCases: TestCase[] = [ + { + name: 'custom relative path', + customDir: '.gemini/tracker', + expected: path.resolve(projectRoot, '.gemini/tracker'), + }, + { + name: 'custom absolute path outside throws', + customDir: '/absolute/path/to/tracker', + expected: '', + expectedError: `Custom tracker directory '/absolute/path/to/tracker' resolves to '/absolute/path/to/tracker', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, + }, + { + name: 'absolute path that happens to be inside project root', + customDir: path.join(projectRoot, '.gemini/tracker'), + expected: path.join(projectRoot, '.gemini/tracker'), + }, + { + name: 'relative path that stays within project root', + customDir: 'subdir/../tracker', + expected: path.resolve(projectRoot, 'tracker'), + }, + { + name: 'dot path', + customDir: '.', + expected: projectRoot, + }, + { + name: 'default behavior when customDir is undefined', + customDir: undefined, + expected: () => storage.getProjectTempTrackerDir(), + }, + { + name: 'escaping relative path throws', + customDir: '../escaped-tracker', + expected: '', + expectedError: `Custom tracker directory '../escaped-tracker' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-tracker'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`, + }, + ]; + + testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { + it(`should handle ${name}`, async () => { + const cleanup = setup?.(); + try { + if (name.includes('default behavior')) { + await storage.initialize(); + } + + storage.setCustomTrackerDir(customDir); + if (expectedError) { + expect(() => storage.getTrackerDir()).toThrow(expectedError); + } else { + const expectedValue = + typeof expected === 'function' ? expected() : expected; + expect(storage.getTrackerDir()).toBe(expectedValue); + } + } finally { + cleanup?.(); + } + }); + }); + }); }); describe('Storage - System Paths', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 38654346fa..2857602086 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -32,6 +32,7 @@ export class Storage { private projectIdentifier: string | undefined; private initPromise: Promise | undefined; private customPlansDir: string | undefined; + private customTrackerDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; @@ -42,6 +43,10 @@ export class Storage { this.customPlansDir = dir; } + setCustomTrackerDir(dir: string | undefined): void { + this.customTrackerDir = dir; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { @@ -328,6 +333,26 @@ export class Storage { return this.getProjectTempPlansDir(); } + getTrackerDir(): string { + if (this.customTrackerDir) { + const resolvedPath = path.resolve( + this.getProjectRoot(), + this.customTrackerDir, + ); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Custom tracker directory '${this.customTrackerDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + return this.getProjectTempTrackerDir(); + } + getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks');