Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Global task defaults** can be set in `<agent-dir>/tasks-config.json` (`~/.pi/agent/tasks-config.json` by default). Project settings in `<cwd>/.pi/tasks-config.json` override global values key by key, and the settings menu persists only project-level differences.

### Changed
- **The auto-cascade setting is easier to find** in `/tasks` → Settings because its label now uses the same "auto-cascade" terminology as the documentation.

## [0.7.1] - 2026-06-24

### Changed
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The extension renders a persistent widget above the editor:

### Widget display settings

How tasks are sorted and how many are shown can be configured via `/tasks` → Settings (saved to `.pi/tasks-config.json`). All defaults preserve the original behaviour.
How tasks are sorted and how many are shown can be configured via `/tasks` → Settings (saved as project overrides in `.pi/tasks-config.json`). All defaults preserve the original behaviour.

| Setting | Values | Default | Behaviour |
|---------|--------|---------|-----------|
Expand Down Expand Up @@ -215,7 +215,21 @@ The `autoClearCompleted` setting controls automatic cleanup of completed tasks:

Both auto-clear modes use a turn-based delay for non-jarring UX — tasks linger briefly so you see the completion before they disappear.

Settings (`taskScope`, `autoCascade`, `autoClearCompleted`, plus the [widget display settings](#widget-display-settings) `sortOrder` / `maxVisible` / `showAll` / `hiddenAt`) are saved to `<cwd>/.pi/tasks-config.json`.
Settings (`taskScope`, `autoCascade`, `autoClearCompleted`, plus the [widget display settings](#widget-display-settings) `sortOrder` / `maxVisible` / `showAll` / `hiddenAt`) changed through `/tasks` are saved as project overrides in `<cwd>/.pi/tasks-config.json`.

### Global defaults

Put settings that should apply across projects in `<agent-dir>/tasks-config.json` (`~/.pi/agent/tasks-config.json` by default). The agent directory follows pi's configured agent path. Project settings in `<cwd>/.pi/tasks-config.json` take precedence key by key.

For example, enable auto-cascade by default for every project:

```json
{
"autoCascade": true
}
```

The `/tasks` settings menu writes only project overrides. Changing another setting in a project does not copy global defaults into that project's config.

### Override via environment variables

Expand Down Expand Up @@ -257,7 +271,7 @@ Tasks
- **Create task** — input prompts for subject and description
- **Clear completed** — remove all completed tasks
- **Clear all** — remove all tasks regardless of status
- **Settings** — configure task storage, auto-cascade, auto-clear completed tasks, and [widget display](#widget-display-settings) (sort order, max visible, show all, hidden position) — saved to `tasks-config.json`
- **Settings** — configure project overrides for task storage, auto-cascade, auto-clear completed tasks, and [widget display](#widget-display-settings) (sort order, max visible, show all, hidden position)

## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents)

Expand Down Expand Up @@ -317,7 +331,7 @@ src/
├── types.ts # Task, TaskStatus, BackgroundProcess types
├── task-store.ts # File-backed store with CRUD, dependencies, locking
├── auto-clear.ts # Turn-based auto-clearing of completed tasks (AutoClearManager)
├── tasks-config.ts # Config persistence (taskScope, autoCascade, autoClearCompleted) → .pi/tasks-config.json
├── tasks-config.ts # Global defaults and project override persistence
├── process-tracker.ts # Background process output buffering and stop
└── ui/
├── task-widget.ts # Persistent widget with status icons and spinner
Expand Down
30 changes: 21 additions & 9 deletions src/tasks-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// <cwd>/.pi/tasks-config.json — persists extension settings across sessions
// <agent-dir>/tasks-config.json provides global defaults.
// <cwd>/.pi/tasks-config.json provides project overrides.

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";

export interface TasksConfig {
taskScope?: "memory" | "session" | "project"; // default: "session"
Expand All @@ -13,15 +15,25 @@ export interface TasksConfig {
hiddenAt?: "top" | "bottom"; // default: "bottom"
}

const CONFIG_PATH = join(process.cwd(), ".pi", "tasks-config.json");

export function loadTasksConfig(): TasksConfig {
function readTasksConfig(configPath: string): TasksConfig {
try {
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
} catch { return {}; }
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf-8"));
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as TasksConfig : {};
} catch {
return {};
}
}

export function loadTasksConfig(cwd = process.cwd(), agentDir = getAgentDir()): TasksConfig {
const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json"));
const projectConfig = readTasksConfig(join(cwd, ".pi", "tasks-config.json"));
return { ...globalConfig, ...projectConfig };
}

export function saveTasksConfig(config: TasksConfig): void {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
export function saveTasksConfig(config: TasksConfig, cwd = process.cwd(), agentDir = getAgentDir()): void {
const configPath = join(cwd, ".pi", "tasks-config.json");
const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json"));
const projectOverrides = Object.fromEntries(Object.entries(config).filter(([key, value]) => globalConfig[key as keyof TasksConfig] !== value));
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(projectOverrides, null, 2));
}
2 changes: 1 addition & 1 deletion src/ui/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function openSettingsMenu(
},
{
id: "autoCascade",
label: "Auto-execute with agents",
label: "Auto-cascade agent tasks",
description:
"When ON: pending agent tasks start automatically once their dependencies complete. " +
"When OFF: use TaskExecute to launch them manually.",
Expand Down
110 changes: 110 additions & 0 deletions test/tasks-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadTasksConfig, saveTasksConfig } from "../src/tasks-config.js";

function writeJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value));
}

describe("tasks config", () => {
let root: string;
let cwd: string;
let agentDir: string;
let globalConfigPath: string;
let projectConfigPath: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "pi-tasks-config-"));
cwd = join(root, "project");
agentDir = join(root, "agent");
globalConfigPath = join(agentDir, "tasks-config.json");
projectConfigPath = join(cwd, ".pi", "tasks-config.json");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

it("returns an empty config when no files exist", () => {
expect(loadTasksConfig(cwd, agentDir)).toEqual({});
});

it("loads global defaults from the agent directory", () => {
writeJson(globalConfigPath, { autoCascade: true, maxVisible: 20 });

expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: true, maxVisible: 20 });
});

it("merges project overrides over global defaults", () => {
writeJson(globalConfigPath, { autoCascade: true, maxVisible: 20, taskScope: "session" });
writeJson(projectConfigPath, { autoCascade: false, maxVisible: 10 });

expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: false, maxVisible: 10, taskScope: "session" });
});

it("ignores a malformed global config", () => {
writeFileSync(globalConfigPath, "{");
writeJson(projectConfigPath, { autoCascade: false });

expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: false });
});

it("falls back to global defaults when the project config is malformed", () => {
writeJson(globalConfigPath, { autoCascade: true });
mkdirSync(dirname(projectConfigPath), { recursive: true });
writeFileSync(projectConfigPath, "{");

expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: true });
});

it("ignores non-object config values", () => {
writeJson(globalConfigPath, ["not", "a", "config"]);
writeJson(projectConfigPath, null);

expect(loadTasksConfig(cwd, agentDir)).toEqual({});
});

it("saves project settings when no global defaults exist", () => {
saveTasksConfig({ autoCascade: true, maxVisible: 15 }, cwd, agentDir);

expect(JSON.parse(readFileSync(projectConfigPath, "utf-8"))).toEqual({ autoCascade: true, maxVisible: 15 });
});

it("saves only values that differ from global defaults", () => {
writeJson(globalConfigPath, { autoCascade: true, maxVisible: 20 });

saveTasksConfig({ autoCascade: true, maxVisible: 30, showAll: false }, cwd, agentDir);

expect(JSON.parse(readFileSync(projectConfigPath, "utf-8"))).toEqual({ maxVisible: 30, showAll: false });
expect(JSON.parse(readFileSync(globalConfigPath, "utf-8"))).toEqual({ autoCascade: true, maxVisible: 20 });
});

it("preserves a project override across save and reload cycles", () => {
writeJson(globalConfigPath, { autoCascade: true, maxVisible: 20 });
const config = loadTasksConfig(cwd, agentDir);
config.autoCascade = false;
saveTasksConfig(config, cwd, agentDir);

const reloaded = loadTasksConfig(cwd, agentDir);
expect(reloaded).toEqual({ autoCascade: false, maxVisible: 20 });
reloaded.maxVisible = 30;
saveTasksConfig(reloaded, cwd, agentDir);

expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: false, maxVisible: 30 });
expect(JSON.parse(readFileSync(projectConfigPath, "utf-8"))).toEqual({ autoCascade: false, maxVisible: 30 });
});

it("writes an empty project override object when effective settings match global defaults", () => {
writeJson(globalConfigPath, { autoCascade: true });

saveTasksConfig({ autoCascade: true }, cwd, agentDir);

expect(existsSync(projectConfigPath)).toBe(true);
expect(JSON.parse(readFileSync(projectConfigPath, "utf-8"))).toEqual({});
});
});