This commit is contained in:
tintinweb
2026-03-17 18:46:39 +01:00
parent c6769fdab1
commit ccddf93590
10 changed files with 160 additions and 46 deletions
+18
View File
@@ -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-<sessionId>.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
+18 -8
View File
@@ -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 `<cwd>/.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 `<cwd>/.pi/tasks-config.json`.
| Mode | File | Behaviour |
|------|------|-----------|
| `memory` | *(none)* | In-memory only — tasks lost when session ends |
| `session` **(default)** | `<cwd>/.pi/tasks/tasks-<sessionId>.json` | Per-session file — isolated between sessions, survives resume |
| `project` | `<cwd>/.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 `<cwd>/.pi/tasks-config.json`.
### Override via environment variables
@@ -190,7 +198,7 @@ Settings (`autoCascade`, `persistTasks`) are saved to `<cwd>/.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 `<cwd>/.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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+78 -13
View File
@@ -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();
}
+16
View File
@@ -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(() => {
+1 -1
View File
@@ -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
}
+13 -12
View File
@@ -28,6 +28,17 @@ export async function openSettingsMenu(
): Promise<void> {
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-<sessionId>.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);
}
},
+4
View File
@@ -75,6 +75,10 @@ export class TaskWidget {
constructor(private store: TaskStore) {}
setStore(store: TaskStore) {
this.store = store;
}
setUICtx(ctx: UICtx) {
this.uiCtx = ctx;
}
+9 -9
View File
@@ -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);
});
});