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
+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;
}