diff --git a/CHANGELOG.md b/CHANGELOG.md index a05de92..5325c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.3] - 2026-03-17 + +### Added +- **Session-scoped task storage** — new `taskScope` config with three modes: `memory` (in-memory only), `session` (per-session file, default), `project` (shared across sessions). Session mode uses `tasks-.json`, surviving session resume while keeping sessions isolated. +- **Session resume support** — `session_switch` event handler reloads persisted tasks on resume without auto-clearing completed tasks (user may want to review). +- **Session file cleanup** — empty session task files are automatically deleted when all tasks are cleared, preventing stale file accumulation. +- **"Clear all" in `/tasks` menu** — wipe all tasks regardless of status, not just completed ones. + +### Changed +- **Unified storage setting** — replaced `persistTasks` (boolean) with a single `taskScope: "memory" | "session" | "project"` setting. The `persistTasks` field is no longer recognized. +- **Auto-clear completed on new session start** — when all persisted tasks are completed, they are silently cleared instead of showing stale completed work. On resume, completed tasks are preserved. +- **Widget only shows on start if there's unfinished work** — sessions with only completed tasks start with a clean slate. +- **Settings moved to last position** in `/tasks` menu for better UX (actions first, config last). + +### Fixed +- **Robust session store upgrade** — store upgrade from in-memory to file-backed triggers on `turn_start`, `before_agent_start`, `session_switch`, and `tool_execution_start` — whichever fires first. + ## [0.3.2] - 2026-03-17 ### Fixed @@ -73,6 +90,7 @@ Initial release — Claude Code-style task tracking and coordination for pi. - **Background process tracker** — output buffering (stdout + stderr), waiter notification, graceful stop with timeout escalation (SIGTERM → 5s → SIGKILL). - **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle. +[0.3.3]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.3 [0.3.2]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.2 [0.3.1]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.1 [0.3.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.0 diff --git a/README.md b/README.md index 43527d9..e63462d 100644 --- a/README.md +++ b/README.md @@ -176,11 +176,19 @@ Tasks are created as `pending`. Mark `in_progress` before starting work, `comple - **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers - **Cleanup on deletion:** removing a task cleans up all edges pointing to it -## Task Persistence +## Task Storage -By default tasks persist locally to `/.pi/tasks/tasks.json` and reload on restart. Only `pending` and `in_progress` tasks are saved — completed tasks are in-memory only and pruned automatically. +Task storage is controlled by the `taskScope` setting (`/tasks` → Settings → Task storage): -Settings (`autoCascade`, `persistTasks`) are saved to `/.pi/tasks-config.json`. +| Mode | File | Behaviour | +|------|------|-----------| +| `memory` | *(none)* | In-memory only — tasks lost when session ends | +| `session` **(default)** | `/.pi/tasks/tasks-.json` | Per-session file — isolated between sessions, survives resume | +| `project` | `/.pi/tasks/tasks.json` | Shared across all sessions in the project | + +On new session start, if all persisted tasks are completed they are auto-cleared for a clean slate. On session resume, all tasks (including completed) are shown so the user can review progress. Empty session files are automatically deleted when all tasks are cleared. + +Settings (`taskScope`, `autoCascade`) are saved to `/.pi/tasks-config.json`. ### Override via environment variables @@ -190,7 +198,7 @@ Settings (`autoCascade`, `persistTasks`) are saved to `/.pi/tasks-config.js | `PI_TASKS` | `sprint-1` | Named shared list at `~/.pi/tasks/sprint-1.json` | | `PI_TASKS` | `/abs/path/tasks.json` | Explicit absolute file path | | `PI_TASKS` | `./tasks.json` | Relative path resolved from cwd | -| *(unset)* | | Default local path `/.pi/tasks/tasks.json` | +| *(unset)* | | Uses `taskScope` setting (default: `session`) | Named and explicit paths use a file-locked store with stale-lock detection — safe for multiple pi sessions coordinating on the same task list. @@ -212,14 +220,16 @@ Interactive menu: Tasks ├─ View all tasks (4) ├─ Create task -├─ Settings -└─ Clear completed (1) +├─ Clear completed (1) +├─ Clear all (4) +└─ Settings ``` - **View all tasks** — select a task to see details and take actions (start, complete, delete) - **Create task** — input prompts for subject and description -- **Settings** — toggle auto-cascade and task persistence (both survive restarts via `tasks-config.json`) - **Clear completed** — remove all completed tasks +- **Clear all** — remove all tasks regardless of status +- **Settings** — configure task storage mode and auto-cascade (saved to `tasks-config.json`) ## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) @@ -278,7 +288,7 @@ src/ ├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration ├── types.ts # Task, TaskStatus, BackgroundProcess types ├── task-store.ts # File-backed store with CRUD, dependencies, locking -├── tasks-config.ts # Config persistence (persistTasks, autoCascade) → .pi/tasks-config.json +├── tasks-config.ts # Config persistence (taskScope, autoCascade) → .pi/tasks-config.json ├── process-tracker.ts # Background process output buffering and stop └── ui/ ├── task-widget.ts # Persistent widget with status icons and spinner diff --git a/package-lock.json b/package-lock.json index ce67e4e..aed9a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tintinweb/pi-tasks", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tintinweb/pi-tasks", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "@mariozechner/pi-coding-agent": "^0.57.1", diff --git a/package.json b/package.json index 0418589..b6bd97a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tintinweb/pi-tasks", - "version": "0.3.2", + "version": "0.3.3", "description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.", "author": "tintinweb", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 7495a3b..fdbbfd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,15 +44,25 @@ export default function (pi: ExtensionAPI) { // Initialize store and config const cfg = loadTasksConfig(); const piTasks = process.env.PI_TASKS; - const localTasksPath = join(process.cwd(), ".pi", "tasks", "tasks.json"); - const store = - piTasks === "off" ? new TaskStore() : - piTasks?.startsWith("/") ? new TaskStore(piTasks) : - piTasks?.startsWith(".") ? new TaskStore(resolve(piTasks)) : - piTasks ? new TaskStore(piTasks) : - cfg.persistTasks === false ? new TaskStore() : - new TaskStore(localTasksPath); + const taskScope = cfg.taskScope ?? "session"; + /** Resolve the task store path from env/config (without session ID). */ + function resolveStorePath(sessionId?: string): string | undefined { + if (piTasks === "off") return undefined; + if (piTasks?.startsWith("/")) return piTasks; + if (piTasks?.startsWith(".")) return resolve(piTasks); + if (piTasks) return piTasks; + if (taskScope === "memory") return undefined; + if (taskScope === "session" && sessionId) { + return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + } + if (taskScope === "session") return undefined; // no session ID yet, start in-memory + return join(process.cwd(), ".pi", "tasks", "tasks.json"); + } + + // For project scope (or env override), create store immediately. + // For session scope, start with in-memory and upgrade once we have the session ID. + let store = new TaskStore(resolveStorePath()); const tracker = new ProcessTracker(); const widget = new TaskWidget(store); @@ -165,13 +175,51 @@ export default function (pi: ExtensionAPI) { widget.update(); }); + // ── Session-scoped store upgrade ── + // For session scope, the store starts in-memory (no session ID at init time). + // Upgrade to file-backed on first context arrival (turn_start, before_agent_start, + // or tool_execution_start — whichever fires first). + let storeUpgraded = false; + let persistedTasksShown = false; + function upgradeStoreIfNeeded(ctx: ExtensionContext) { + if (storeUpgraded) return; + if (taskScope === "session" && !piTasks) { + const sessionId = ctx.sessionManager.getSessionId(); + const path = resolveStorePath(sessionId); + store = new TaskStore(path); + widget.setStore(store); + } + storeUpgraded = true; + } + + /** Restore widget on session start/resume if there's unfinished work. + * On new sessions, auto-clear if all tasks are completed (clean slate). + * On resume, always show tasks (user may want to review). + * Only runs once — the first caller wins. */ + function showPersistedTasks(isResume = false) { + if (persistedTasksShown) return; + persistedTasksShown = true; + const tasks = store.list(); + if (tasks.length > 0) { + if (!isResume && tasks.every(t => t.status === "completed")) { + store.clearCompleted(); + if (taskScope === "session") store.deleteFileIfEmpty(); + } else { + widget.update(); + } + } + } + // ── Turn tracking for system-reminder injection ── let currentTurn = 0; let lastTaskToolUseTurn = 0; let reminderInjectedThisCycle = false; - pi.on("turn_start", async () => { + pi.on("turn_start", async (_event, ctx) => { currentTurn++; + latestCtx = ctx; + widget.setUICtx(ctx.ui as UICtx); + upgradeStoreIfNeeded(ctx); }); // ── Token usage tracking ── @@ -211,17 +259,27 @@ export default function (pi: ExtensionAPI) { }); // Grab UI context early — before_agent_start fires before any tool calls, - // so persisted tasks show up immediately on session resume. + // so persisted tasks show up immediately on session start. pi.on("before_agent_start", async (_event, ctx) => { latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); - if (store.list().length > 0) widget.update(); + upgradeStoreIfNeeded(ctx); + showPersistedTasks(); + }); + + // session_switch fires on resume (reason: "resume") — reload persisted tasks. + pi.on("session_switch" as any, async (event: any, ctx: ExtensionContext) => { + latestCtx = ctx; + widget.setUICtx(ctx.ui as UICtx); + upgradeStoreIfNeeded(ctx); + showPersistedTasks(event?.reason === "resume"); }); // Keep latestCtx fresh on every tool execution as well. pi.on("tool_execution_start", async (_event, ctx) => { latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); + upgradeStoreIfNeeded(ctx); widget.update(); }); @@ -741,9 +799,10 @@ Set up task dependencies: const choices: string[] = [ `View all tasks (${taskCount})`, "Create task", - "Settings", ]; if (completedCount > 0) choices.push(`Clear completed (${completedCount})`); + if (taskCount > 0) choices.push(`Clear all (${taskCount})`); + choices.push("Settings"); const choice = await ui.select("Tasks", choices); if (!choice) return; @@ -754,8 +813,14 @@ Set up task dependencies: await createTask(); } else if (choice === "Settings") { await settingsMenu(); - } else if (choice.startsWith("Clear")) { + } else if (choice.startsWith("Clear completed")) { store.clearCompleted(); + if (taskScope === "session") store.deleteFileIfEmpty(); + widget.update(); + await mainMenu(); + } else if (choice.startsWith("Clear all")) { + store.clearAll(); + if (taskScope === "session") store.deleteFileIfEmpty(); widget.update(); await mainMenu(); } diff --git a/src/task-store.ts b/src/task-store.ts index aa21e8e..88be613 100644 --- a/src/task-store.ts +++ b/src/task-store.ts @@ -265,6 +265,22 @@ export class TaskStore { }); } + /** Remove all tasks. */ + clearAll(): number { + return this.withLock(() => { + const count = this.tasks.size; + this.tasks.clear(); + return count; + }); + } + + /** Delete the backing file (if file-backed and empty). */ + deleteFileIfEmpty(): boolean { + if (!this.filePath || this.tasks.size > 0) return false; + try { unlinkSync(this.filePath); } catch { /* ignore */ } + return true; + } + /** Remove all completed tasks. */ clearCompleted(): number { return this.withLock(() => { diff --git a/src/tasks-config.ts b/src/tasks-config.ts index 0aecd93..b686c50 100644 --- a/src/tasks-config.ts +++ b/src/tasks-config.ts @@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; export interface TasksConfig { - persistTasks?: boolean; // default: true + taskScope?: "memory" | "session" | "project"; // default: "session" autoCascade?: boolean; // default: false } diff --git a/src/ui/settings-menu.ts b/src/ui/settings-menu.ts index 5245239..fd7d6de 100644 --- a/src/ui/settings-menu.ts +++ b/src/ui/settings-menu.ts @@ -28,6 +28,17 @@ export async function openSettingsMenu( ): Promise { await ui.custom((_tui, theme, _kb, done) => { const items: SettingItem[] = [ + { + id: "taskScope", + label: "Task storage", + description: + "memory: tasks live only in memory, lost when session ends. " + + "session: persisted per session (tasks-.json), survives resume. " + + "project: shared across all sessions (tasks.json). " + + "Takes effect on next session start.", + currentValue: cfg.taskScope ?? "session", + values: ["memory", "session", "project"], + }, { id: "autoCascade", label: "Auto-execute with agents", @@ -37,16 +48,6 @@ export async function openSettingsMenu( currentValue: (cfg.autoCascade ?? false) ? "on" : "off", values: ["on", "off"], }, - { - id: "persist", - label: "Persist tasks across sessions", - description: - "When ON: pending and in-progress tasks are saved to .pi/tasks/tasks.json so they " + - "survive a restart. Completed tasks are never written to disk. " + - "Toggle takes effect on next session start.", - currentValue: (cfg.persistTasks ?? true) ? "on" : "off", - values: ["on", "off"], - }, ]; const list = new SettingsList( @@ -58,8 +59,8 @@ export async function openSettingsMenu( cfg.autoCascade = newValue === "on"; saveTasksConfig(cfg); } - if (id === "persist") { - cfg.persistTasks = newValue === "on"; + if (id === "taskScope") { + cfg.taskScope = newValue as "memory" | "session" | "project"; saveTasksConfig(cfg); } }, diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index 56dc4b9..7311bdd 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -75,6 +75,10 @@ export class TaskWidget { constructor(private store: TaskStore) {} + setStore(store: TaskStore) { + this.store = store; + } + setUICtx(ctx: UICtx) { this.uiCtx = ctx; } diff --git a/test/task-store.test.ts b/test/task-store.test.ts index 8e4c757..21f1d24 100644 --- a/test/task-store.test.ts +++ b/test/task-store.test.ts @@ -347,19 +347,20 @@ describe("TaskStore (file-backed)", () => { expect(store2.get("1")!.status).toBe("in_progress"); }); - it("does not persist completed tasks to disk", () => { + it("persists completed tasks to disk", () => { const store1 = new TaskStore(testListId); store1.create("Done task", "Desc"); store1.create("Pending task", "Desc"); store1.update("1", { status: "completed" }); const store2 = new TaskStore(testListId); - expect(store2.get("1")).toBeUndefined(); + expect(store2.get("1")).toBeDefined(); + expect(store2.get("1")!.status).toBe("completed"); expect(store2.get("2")).toBeDefined(); - expect(store2.list()).toHaveLength(1); + expect(store2.list()).toHaveLength(2); }); - it("only restores pending and in_progress tasks across instances", () => { + it("restores all tasks across instances", () => { const store1 = new TaskStore(testListId); store1.create("Pending", "Desc"); store1.create("In progress", "Desc"); @@ -369,10 +370,10 @@ describe("TaskStore (file-backed)", () => { const store2 = new TaskStore(testListId); const tasks = store2.list(); - expect(tasks).toHaveLength(2); + expect(tasks).toHaveLength(3); expect(tasks.map(t => t.id)).toContain("1"); expect(tasks.map(t => t.id)).toContain("2"); - expect(tasks.map(t => t.id)).not.toContain("3"); + expect(tasks.map(t => t.id)).toContain("3"); }); it("persists ID counter across instances", () => { @@ -404,14 +405,13 @@ describe("TaskStore (absolute path)", () => { expect(store2.list()[0].subject).toBe("Abs path task"); }); - it("does not persist completed tasks when using absolute path", () => { + it("persists completed tasks when using absolute path", () => { const store1 = new TaskStore(absFilePath); store1.create("Pending", "Desc"); store1.create("Completed", "Desc"); store1.update("2", { status: "completed" }); const raw = JSON.parse(readFileSync(absFilePath, "utf-8")); - expect(raw.tasks).toHaveLength(1); - expect(raw.tasks[0].id).toBe("1"); + expect(raw.tasks).toHaveLength(2); }); });