Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ When both legacy settings are present with different values, the migration follo

### Available settings in `settings.json`

Settings are organized into categories. Most settings should be placed within their corresponding top-level category object in your `settings.json` file. A few top-level settings like `proxy` and `plansDirectory` remain direct root keys for compatibility.
Settings are organized into categories. Most settings should be placed within their corresponding top-level category object in your `settings.json` file. A few top-level settings like `proxy`, `plansDirectory`, and `todosDirectory` remain direct root keys for compatibility.

#### general

Expand Down Expand Up @@ -463,6 +463,7 @@ LSP server configuration is done through `.lsp.json` files in your project root
| `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` |
| `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` |
| `plansDirectory` | string | Custom directory for approved Plan Mode files. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, plan files are stored in `~/.qwen/plans`. **Requires restart.** If the directory is inside the project root, add it to `.gitignore` to avoid committing plan files. | `undefined` |
| `todosDirectory` | string | Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, todo files are stored in the global runtime todos directory (`~/.qwen/todos`). **Requires restart.** | `undefined` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The parallel plansDirectory row (line 465) ends with "add it to .gitignore to avoid committing plan files." This row drops that guidance — yet the example at line 523 uses "todosDirectory": "./.qwen/todos" (inside the project root), which would commit per-session todo JSON (named by session UUID) into the repo.

Suggested change
| `todosDirectory` | string | Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, todo files are stored in the global runtime todos directory (`~/.qwen/todos`). **Requires restart.** | `undefined` |
| `todosDirectory` | string | Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. If unset, todo files are stored in the global runtime todos directory (`~/.qwen/todos`). **Requires restart.** If the directory is inside the project root, add it to `.gitignore` to avoid committing todo files. | `undefined` |

— claude-opus-4-8 via Claude Code /qreview


#### experimental

Expand Down Expand Up @@ -519,6 +520,7 @@ Here is an example of a `settings.json` file with the nested structure, new as o
{
"proxy": "http://localhost:7890",
"plansDirectory": "./.qwen/plans",
"todosDirectory": "./.qwen/todos",
"general": {
"vimMode": true,
"preferredEditor": "code"
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,7 @@ export async function loadCliConfig(
clearContextOnIdle: settings.context?.clearContextOnIdle,
fileFiltering: settings.context?.fileFiltering,
plansDirectory: settings.plansDirectory,
todosDirectory: settings.todosDirectory,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This is the only place where the real CLI settings object is wired into Config, but the new todosDirectory pass-through has no integration test. The storage tests cover Storage.getTodosDir() directly, and the tool tests use a mocked getTodosDir(), so a future typo or omission here could make the documented setting silently ignored while those tests still pass. Please add loadCliConfig todosDirectory tests beside the existing plansDirectory block that verify a relative value resolves against cwd via config.getTodosDir() and that ../todos rejects with the containment error.

— GPT-5 via Qwen Code /review

proxy:
argv.proxy ||
settings.proxy ||
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('SettingsSchema', () => {
'security',
'advanced',
'plansDirectory',
'todosDirectory',
'voiceModel',
];

Expand Down Expand Up @@ -168,6 +169,15 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().plansDirectory.showInDialog).toBe(false);
});

it('should have todosDirectory setting in schema', () => {
expect(getSettingsSchema().todosDirectory).toBeDefined();
expect(getSettingsSchema().todosDirectory.type).toBe('string');
expect(getSettingsSchema().todosDirectory.category).toBe('Advanced');
expect(getSettingsSchema().todosDirectory.default).toBe(undefined);
expect(getSettingsSchema().todosDirectory.requiresRestart).toBe(true);
expect(getSettingsSchema().todosDirectory.showInDialog).toBe(false);
});

it('should have voice model setting in schema', () => {
const voiceModel = getSettingsSchema().voiceModel;

Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
},

todosDirectory: {
type: 'string',
label: 'Todos Directory',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Custom directory for todo list files created by the todo-write tool. Relative paths are resolved from the project root, and the resolved path must stay within the project root. Defaults to the global runtime todos directory (~/.qwen/todos).',
showInDialog: false,
},

// Environment variables fallback
env: {
type: 'object',
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,7 @@ export interface ConfigParameters {
fileCheckpointingEnabled?: boolean;
/** Directory where approved plan files are stored. Must resolve inside targetDir. */
plansDirectory?: string;
todosDirectory?: string;
proxy?: string;
cwd: string;
fileDiscoveryService?: FileDiscoveryService;
Expand Down Expand Up @@ -1435,6 +1436,7 @@ export class Config {
private readonly jsonSchema: Record<string, unknown> | undefined;
private readonly inputFile: string | undefined;
private readonly plansDir: string;
private readonly todosDir: string;
private readonly plansDirectoryConfigured: boolean;
private readonly defaultFileEncoding: FileEncodingType | undefined;
private readonly enableManagedAutoMemory: boolean;
Expand Down Expand Up @@ -1477,6 +1479,7 @@ export class Config {
this.targetDir = path.resolve(params.targetDir);
this.plansDirectoryConfigured = Boolean(params.plansDirectory?.trim());
this.plansDir = Storage.getPlansDir(this.targetDir, params.plansDirectory);
this.todosDir = Storage.getTodosDir(this.targetDir, params.todosDirectory);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When todosDirectory is set, the parallel plansDirectory path warns that files in the legacy location are now unreferenced — addLegacyPlanLocationWarning() (config.ts ~4137, called from the constructor ~1633, surfaced via getWarnings()). There is no addLegacyTodoLocationWarning equivalent, so a user who opts in silently stops seeing their existing ~/.qwen/todos/*.json: no warning, no log, the session just starts with an empty todo list. This contradicts the PR's "migration notes: none."

Suggest mirroring the plans warning — guard on a todosDirectoryConfigured flag, compare Storage.getRuntimeBaseDir()/todos (runtime base, not getGlobalQwenDir()) against the configured dir, and push a "move your existing todo files" warning.

— claude-opus-4-8 via Claude Code /qreview

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing Config-level test coverage for todosDirectory wiring.

The parallel plansDirectory has a full Config-level test suite in config.test.ts (~lines 5015–5283) covering constructor path resolution, escape-path rejection, and getter behavior. No equivalent exists for todosDirectory — the Storage.getTodosDir unit tests in storage.test.ts cover the helper in isolation, but the Config constructor's threading of params.todosDirectoryStorage.getTodosDir(this.targetDir, ...)this.todosDirgetTodosDir() is untested.

A regression in the constructor (e.g., passing params.todosDirectory in the wrong position, or using this.cwd instead of this.targetDir) would silently produce a wrong directory with no test catching it.

Consider adding two tests mirroring the plansDirectory ones:

  1. Construct Config with todosDirectory: './.qwen/todos' and assert config.getTodosDir() equals path.join(targetDir, '.qwen/todos')
  2. Construct with todosDirectory: '../todos' and assert the constructor throws FatalConfigError

— qwen3.7-max via Qwen Code /review

this.explicitIncludeDirectories = Array.from(
new Set(params.includeDirectories ?? []),
);
Expand Down Expand Up @@ -4103,6 +4106,10 @@ export class Config {
return this.plansDir;
}

getTodosDir(): string {
return this.todosDir;
}

private assertPlansDirWithinTargetDir(): void {
if (!this.plansDirectoryConfigured) {
return;
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/config/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { Storage } from './storage.js';
import { FatalConfigError } from '../utils/errors.js';

const mockRealpathSync = vi.hoisted(() => vi.fn());

Expand Down Expand Up @@ -346,6 +347,65 @@ describe('Storage – getPlansDir', () => {
});
});

describe('Storage – getTodosDir', () => {
const projectRoot = path.resolve('workspace', 'project');
const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR'];

beforeEach(() => {
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
mockRealpathSync.mockImplementation((pathToResolve) =>
actualFs.realpathSync(pathToResolve),
);
});

afterEach(() => {
Storage.setRuntimeBaseDir(null);
if (originalRuntimeEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
mockRealpathSync.mockReset();
});

it('defaults to the runtime base todos directory when todosDirectory is not configured', () => {
const runtimeDir = path.resolve('custom', 'runtime');
Storage.setRuntimeBaseDir(runtimeDir);

expect(Storage.getTodosDir(projectRoot)).toBe(
path.join(Storage.getRuntimeBaseDir(), 'todos'),
);
});

it('resolves relative todosDirectory values against the project root', () => {
expect(Storage.getTodosDir(projectRoot, './.qwen/todos')).toBe(
path.join(projectRoot, '.qwen', 'todos'),
);
});

it('rejects todosDirectory values that escape the project root', () => {
expect(() => Storage.getTodosDir(projectRoot, '../todos')).toThrow(
FatalConfigError,
);
expect(() => Storage.getTodosDir(projectRoot, '../todos')).toThrow(
'todosDirectory must resolve within the project root',
);
});

it('requires projectRoot when todosDirectory is configured', () => {
expect(() => Storage.getTodosDir(undefined, './todos')).toThrow(
FatalConfigError,
);
expect(() => Storage.getTodosDir(undefined, './todos')).toThrow(
'projectRoot is required when todosDirectory is configured',
);
expect(() => Storage.getTodosDir(null, './todos')).toThrow(
'projectRoot is required when todosDirectory is configured',
);
});
});

describe('Storage – runtime path methods use getRuntimeBaseDir', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];

Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
const PLANS_DIR_NAME = 'plans';
const TODOS_DIR_NAME = 'todos';
const DEBUG_DIR_NAME = 'debug';
const ARENA_DIR_NAME = 'arena';

Expand Down Expand Up @@ -298,6 +299,36 @@ export class Storage {
);
}

static getTodosDir(
projectRoot?: string | null,
todosDirectory?: string | null,
): string {
const configuredTodosDirectory = todosDirectory?.trim();
if (configuredTodosDirectory) {
if (!projectRoot) {
throw new FatalConfigError(
'projectRoot is required when todosDirectory is configured.',
);
}

const resolvedProjectRoot = path.resolve(projectRoot);
const resolvedTodosDirectory = Storage.resolvePath(
configuredTodosDirectory,
resolvedProjectRoot,
);

Storage.assertPathWithinDirectory(
resolvedTodosDirectory,
resolvedProjectRoot,
`todosDirectory must resolve within the project root.`,
);

return resolvedTodosDirectory;
}

return path.join(Storage.getRuntimeBaseDir(), TODOS_DIR_NAME);
}

static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
}
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/tools/todoWrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TodoWriteTool, listTodoSessions } from './todoWrite.js';
import { DefaultHookOutput, HookPhase, type TodoItem } from '../hooks/types.js';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Config } from '../config/config.js';
import type { AggregatedHookResult } from '../hooks/hookAggregator.js';
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => undefined,
getTodosDir: () => Storage.getTodosDir(),
} as Config;
tool = new TodoWriteTool(mockConfig);
mockAbortSignal = new AbortController().signal;
Expand Down Expand Up @@ -296,6 +298,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -350,6 +353,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -416,6 +420,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -493,6 +498,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -543,6 +549,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -610,6 +617,7 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => mockHookSystem,
getTodosDir: () => Storage.getTodosDir(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);

Expand Down Expand Up @@ -699,6 +707,7 @@ describe('TodoWriteTool – runtime output directory', () => {
mockConfig = {
getSessionId: () => 'runtime-session',
getHookSystem: () => undefined,
getTodosDir: () => Storage.getTodosDir(),
} as Config;
tool = new TodoWriteTool(mockConfig);
mockAbortSignal = new AbortController().signal;
Expand Down Expand Up @@ -782,6 +791,57 @@ describe('TodoWriteTool – runtime output directory', () => {
expect(writePath).toContain(path.join('.qwen', 'todos'));
});

it('should read and write todos from the configured todos directory', async () => {
const configuredTodoDir = path.join(os.tmpdir(), 'qwen-configured-todos');
mockConfig = {
getSessionId: () => 'configured-session',
getHookSystem: () => undefined,
getTodosDir: () => configuredTodoDir,
} as Config;
tool = new TodoWriteTool(mockConfig);

const existingTodos: TodoItem[] = [
{ id: '1', content: 'Existing task', status: 'pending' },
];
const params: TodoWriteParams = {
todos: [{ id: '1', content: 'Existing task', status: 'completed' }],
};
const todoFilePath = path.join(
configuredTodoDir,
'configured-session.json',
);

mockFs.readFile.mockResolvedValue(
JSON.stringify({
todos: existingTodos,
sessionId: 'configured-session',
}),
);
mockFs.mkdir.mockResolvedValue(undefined);
mockAtomicWrite.mockResolvedValue(undefined);

const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);

expect(mockFs.readFile).toHaveBeenCalledWith(todoFilePath, 'utf-8');
expect(mockFs.mkdir).toHaveBeenCalledWith(configuredTodoDir, {
recursive: true,
});
expect(mockAtomicWrite).toHaveBeenCalledWith(
todoFilePath,
expect.any(String),
{ encoding: 'utf-8' },
);
const persisted = JSON.parse(mockAtomicWrite.mock.calls[0]?.[1] as string);
expect(persisted).toEqual({
todos: params.todos,
sessionId: 'configured-session',
});
expect(result.llmContent).toContain(
'Todos have been modified successfully',
);
});

it('should check file existence in custom runtime dir for getDescription', () => {
const customRuntimeDir = path.resolve('custom', 'runtime');
Storage.setRuntimeBaseDir(customRuntimeDir);
Expand Down
Loading
Loading