mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:31:06 +08:00
v0.3.3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -75,6 +75,10 @@ export class TaskWidget {
|
||||
|
||||
constructor(private store: TaskStore) {}
|
||||
|
||||
setStore(store: TaskStore) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
setUICtx(ctx: UICtx) {
|
||||
this.uiCtx = ctx;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user